Skip to content

Commit 124e5e4

Browse files
committed
Fix PR lens review publication idempotency
1 parent b454129 commit 124e5e4

2 files changed

Lines changed: 181 additions & 48 deletions

File tree

.github/scripts/evalops-pr-lens-review.rb

Lines changed: 71 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,11 +1268,28 @@ def finding_inline_comment_body(finding)
12681268
].join("\n")
12691269
end
12701270

1271-
# Build the review summary body. `inline_findings` are anchored to the diff as
1272-
# individual comments; `summary_findings` could not be anchored (their line is
1273-
# not part of the diff) and are listed here with their path:line instead.
1274-
def review_summary_body(repo:, pr:, inline_findings:, summary_findings:, comment_min_confidence:, target_url:)
1275-
total = inline_findings.length + summary_findings.length
1271+
def append_summary_findings(lines, title, findings, omission_label:)
1272+
return if findings.empty?
1273+
1274+
lines << title
1275+
findings.first(MAX_FINDINGS_PER_COMMENT).each do |finding|
1276+
location = finding.fetch("code_location")
1277+
lines << "- **P#{finding.fetch("priority")} #{format("%.2f", finding.fetch("confidence_score"))} #{finding.fetch("lens")}** `#{location.fetch("path")}:#{location.fetch("line")}`: #{finding.fetch("title")}"
1278+
lines << " - #{finding.fetch("body")}"
1279+
end
1280+
if findings.length > MAX_FINDINGS_PER_COMMENT
1281+
lines << "- _#{findings.length - MAX_FINDINGS_PER_COMMENT} additional #{omission_label} omitted; inspect the workflow artifact for the full ledger._"
1282+
end
1283+
lines << ""
1284+
end
1285+
1286+
# Build the summary comment body. `inline_findings` are anchored to the diff as
1287+
# individual comments. If there are more anchorable findings than the inline
1288+
# comment cap allows, `overflow_inline_findings` are listed in the summary with
1289+
# their locations. `summary_findings` could not be anchored because their line
1290+
# is not part of the diff and are also listed here with path:line.
1291+
def review_summary_body(repo:, pr:, inline_findings:, overflow_inline_findings:, summary_findings:, comment_min_confidence:, target_url:)
1292+
total = inline_findings.length + overflow_inline_findings.length + summary_findings.length
12761293
lines = [
12771294
MARKER,
12781295
"**EvalOps PR lens review**",
@@ -1287,18 +1304,18 @@ def review_summary_body(repo:, pr:, inline_findings:, summary_findings:, comment
12871304
lines << ""
12881305
end
12891306

1290-
unless summary_findings.empty?
1291-
lines << "Findings outside the diff (not inline-anchorable):"
1292-
summary_findings.first(MAX_FINDINGS_PER_COMMENT).each do |finding|
1293-
location = finding.fetch("code_location")
1294-
lines << "- **P#{finding.fetch("priority")} #{format("%.2f", finding.fetch("confidence_score"))} #{finding.fetch("lens")}** `#{location.fetch("path")}:#{location.fetch("line")}`: #{finding.fetch("title")}"
1295-
lines << " - #{finding.fetch("body")}"
1296-
end
1297-
if summary_findings.length > MAX_FINDINGS_PER_COMMENT
1298-
lines << "- _#{summary_findings.length - MAX_FINDINGS_PER_COMMENT} additional finding(s) omitted; inspect the workflow artifact for the full ledger._"
1299-
end
1300-
lines << ""
1301-
end
1307+
append_summary_findings(
1308+
lines,
1309+
"Additional diff findings (not posted inline due to the #{MAX_FINDINGS_PER_COMMENT}-comment cap):",
1310+
overflow_inline_findings,
1311+
omission_label: "diff finding(s)"
1312+
)
1313+
append_summary_findings(
1314+
lines,
1315+
"Findings outside the diff (not inline-anchorable):",
1316+
summary_findings,
1317+
omission_label: "finding(s)"
1318+
)
13021319

13031320
lines << "_Repo: #{repo} PR: ##{pr}_"
13041321
lines.join("\n")
@@ -1339,6 +1356,27 @@ def delete_marker_review_comments(repo:, pr:)
13391356
end
13401357
end
13411358

1359+
def post_summary_comment(repo:, pr:, body:)
1360+
gh_api(
1361+
"--method", "POST", "repos/#{repo}/issues/#{pr}/comments",
1362+
input: JSON.generate(body: body)
1363+
)
1364+
end
1365+
1366+
def post_inline_comment(repo:, pr:, head_sha:, finding:)
1367+
location = finding.fetch("code_location")
1368+
gh_api(
1369+
"--method", "POST", "repos/#{repo}/pulls/#{pr}/comments",
1370+
input: JSON.generate(
1371+
commit_id: head_sha,
1372+
path: location.fetch("path"),
1373+
line: Integer(location.fetch("line")),
1374+
side: "RIGHT",
1375+
body: finding_inline_comment_body(finding)
1376+
)
1377+
)
1378+
end
1379+
13421380
# Remove the bot's prior published artifacts for this PR (marker issue-comment
13431381
# left by the legacy code path, and prior marker inline review comments) so the
13441382
# publication is idempotent across re-runs on the same head.
@@ -1347,42 +1385,34 @@ def clear_prior_publication(repo:, pr:)
13471385
delete_marker_review_comments(repo: repo, pr: pr)
13481386
end
13491387

1350-
# Publish findings as a single PR review: a summary body plus inline comments
1388+
# Publish findings as a marker summary issue comment plus inline PR comments
13511389
# anchored to each anchorable finding's code_location. Findings whose line is
1352-
# not in the diff are folded into the summary body. Idempotent: prior marker
1353-
# comments are deleted first.
1390+
# not in the diff, or that overflow the inline comment cap, are folded into the
1391+
# summary body. Idempotent: prior marker comments are deleted first.
13541392
def publish_review(repo:, pr:, head_sha:, inline_findings:, summary_findings:, comment_min_confidence:, target_url:)
13551393
clear_prior_publication(repo: repo, pr: pr)
13561394
return if inline_findings.empty? && summary_findings.empty?
13571395

1358-
comments = inline_findings.first(MAX_FINDINGS_PER_COMMENT).map do |finding|
1359-
location = finding.fetch("code_location")
1360-
{
1361-
path: location.fetch("path"),
1362-
line: Integer(location.fetch("line")),
1363-
side: "RIGHT",
1364-
body: finding_inline_comment_body(finding)
1365-
}
1366-
end
1396+
inline_to_publish = inline_findings.first(MAX_FINDINGS_PER_COMMENT)
1397+
overflow_inline_findings = inline_findings.drop(MAX_FINDINGS_PER_COMMENT)
13671398

1368-
payload = {
1369-
commit_id: head_sha,
1370-
event: "COMMENT",
1399+
post_summary_comment(
1400+
repo: repo,
1401+
pr: pr,
13711402
body: review_summary_body(
13721403
repo: repo,
13731404
pr: pr,
1374-
inline_findings: inline_findings,
1405+
inline_findings: inline_to_publish,
1406+
overflow_inline_findings: overflow_inline_findings,
13751407
summary_findings: summary_findings,
13761408
comment_min_confidence: comment_min_confidence,
13771409
target_url: target_url
1378-
),
1379-
comments: comments
1380-
}
1381-
1382-
gh_api(
1383-
"--method", "POST", "repos/#{repo}/pulls/#{pr}/reviews",
1384-
input: JSON.generate(payload)
1410+
)
13851411
)
1412+
1413+
inline_to_publish.each do |finding|
1414+
post_inline_comment(repo: repo, pr: pr, head_sha: head_sha, finding: finding)
1415+
end
13861416
end
13871417

13881418
def blocking_findings(findings, block_min_confidence:)
@@ -1649,6 +1679,7 @@ def markdown_meta_report(result)
16491679
# single knob and now maps to the *block* threshold. The comment threshold
16501680
# defaults lower so medium-confidence findings are still shown.
16511681
legacy_block = ENV["PR_LENS_MIN_CONFIDENCE"]
1682+
legacy_block = nil if legacy_block.to_s.empty?
16521683
options = {
16531684
comment_min_confidence: Float(
16541685
ENV.fetch("PR_LENS_COMMENT_MIN_CONFIDENCE", EvalOpsPrLensReview::DEFAULT_COMMENT_MIN_CONFIDENCE)

test/evalops_pr_lens_review_test.rb

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "json"
44
require "minitest/autorun"
5+
require "open3"
56
require "set"
67
require "stringio"
78
require "tmpdir"
@@ -348,6 +349,7 @@ def test_review_summary_body_lists_inline_and_off_diff_findings
348349
repo: "evalops/deploy",
349350
pr: 10,
350351
inline_findings: inline,
352+
overflow_inline_findings: [],
351353
summary_findings: summary,
352354
comment_min_confidence: 0.55,
353355
target_url: "https://github.com/evalops/.github/actions/runs/1"
@@ -360,6 +362,41 @@ def test_review_summary_body_lists_inline_and_off_diff_findings
360362
assert_includes body, "`infra/old.tf:9`"
361363
end
362364

365+
def test_review_summary_body_lists_inline_overflow_in_summary
366+
inline = Array.new(EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT) do |index|
367+
finding("Inline #{index}", 0.80, 1, "infra/main.tf", index + 1).merge(
368+
"repo" => "evalops/deploy",
369+
"pr" => 10,
370+
"lens" => "iam-blast-radius",
371+
"head_sha" => "abc123",
372+
"check_id" => "evalops-pr-lens/iam-blast-radius"
373+
)
374+
end
375+
overflow = [
376+
finding("Overflow inline", 0.79, 2, "infra/main.tf", 99).merge(
377+
"repo" => "evalops/deploy",
378+
"pr" => 10,
379+
"lens" => "iam-blast-radius",
380+
"head_sha" => "abc123",
381+
"check_id" => "evalops-pr-lens/iam-blast-radius"
382+
)
383+
]
384+
385+
body = EvalOpsPrLensReview.review_summary_body(
386+
repo: "evalops/deploy",
387+
pr: 10,
388+
inline_findings: inline,
389+
overflow_inline_findings: overflow,
390+
summary_findings: [],
391+
comment_min_confidence: 0.55,
392+
target_url: "https://github.com/evalops/.github/actions/runs/1"
393+
)
394+
395+
assert_includes body, "#{EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT} anchored inline below."
396+
assert_includes body, "Additional diff findings"
397+
assert_includes body, "`infra/main.tf:99`"
398+
end
399+
363400
def test_finding_inline_comment_body_carries_marker_and_check
364401
body = EvalOpsPrLensReview.finding_inline_comment_body(
365402
finding("Unsafe IAM expansion", 0.94, 1, "infra/main.tf", 22).merge(
@@ -859,7 +896,7 @@ def test_meta_review_green_status_states_coverage_when_only_low_confidence
859896
end
860897
end
861898

862-
def test_publish_review_posts_pr_review_and_is_idempotent
899+
def test_publish_review_posts_summary_comment_and_inline_comments_idempotently
863900
api_calls = []
864901
fake_api = lambda do |*args, **kwargs|
865902
api_calls << { args: args, input: kwargs[:input] }
@@ -889,17 +926,58 @@ def test_publish_review_posts_pr_review_and_is_idempotent
889926

890927
# Prior publication is cleared before posting (idempotency).
891928
assert_equal({ clear: true }, api_calls.fetch(0))
892-
review_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/reviews") }
893-
assert review_call
894-
payload = JSON.parse(review_call.fetch(:input))
895-
assert_equal "COMMENT", payload.fetch("event")
896-
assert_equal "abc123", payload.fetch("commit_id")
897-
assert_equal 1, payload.fetch("comments").length
898-
comment = payload.fetch("comments").fetch(0)
929+
summary_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments") }
930+
assert summary_call
931+
summary_payload = JSON.parse(summary_call.fetch(:input))
932+
assert_includes summary_payload.fetch("body"), EvalOpsPrLensReview::MARKER
933+
934+
comment_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments") }
935+
assert comment_call
936+
comment = JSON.parse(comment_call.fetch(:input))
937+
assert_equal "abc123", comment.fetch("commit_id")
899938
assert_equal "infra/main.tf", comment.fetch("path")
900939
assert_equal 22, comment.fetch("line")
901940
assert_equal "RIGHT", comment.fetch("side")
902941
assert_includes comment.fetch("body"), EvalOpsPrLensReview::MARKER
942+
refute(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/reviews") })
943+
end
944+
945+
def test_publish_review_moves_inline_overflow_into_summary_comment
946+
api_calls = []
947+
fake_api = lambda do |*args, **kwargs|
948+
api_calls << { args: args, input: kwargs[:input] }
949+
""
950+
end
951+
inline = Array.new(EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT + 1) do |index|
952+
finding("Inline #{index}", 0.9, 1, "infra/main.tf", index + 1).merge(
953+
"lens" => "iam-blast-radius",
954+
"check_id" => "evalops-pr-lens/iam-blast-radius"
955+
)
956+
end
957+
958+
EvalOpsPrLensReview.stub(:clear_prior_publication, ->(**_kwargs) {}) do
959+
EvalOpsPrLensReview.stub(:gh_api, fake_api) do
960+
EvalOpsPrLensReview.publish_review(
961+
repo: "evalops/deploy",
962+
pr: 10,
963+
head_sha: "abc123",
964+
inline_findings: inline,
965+
summary_findings: [],
966+
comment_min_confidence: 0.55,
967+
target_url: "https://github.com/evalops/.github/actions/runs/1"
968+
)
969+
end
970+
end
971+
972+
summary_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments") }
973+
assert summary_call
974+
summary_body = JSON.parse(summary_call.fetch(:input)).fetch("body")
975+
assert_includes summary_body, "#{EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT} anchored inline below."
976+
assert_includes summary_body, "Additional diff findings"
977+
assert_includes summary_body, "`infra/main.tf:13`"
978+
979+
inline_posts = api_calls.count { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments") }
980+
assert_equal EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT, inline_posts
903981
end
904982

905983
def test_publish_review_clears_prior_then_skips_post_when_no_findings
@@ -956,6 +1034,30 @@ def test_meta_review_back_compat_min_confidence_maps_to_block_threshold
9561034
assert_equal "failure", EvalOpsPrLensReview.meta_state(findings, block_min_confidence: 0.65)
9571035
end
9581036

1037+
def test_meta_review_cli_ignores_empty_legacy_block_env
1038+
Dir.mktmpdir do |dir|
1039+
output = File.join(dir, "meta-review.json")
1040+
script = File.expand_path("../.github/scripts/evalops-pr-lens-review.rb", __dir__)
1041+
stdout, stderr, status = Open3.capture3(
1042+
{
1043+
"PR_LENS_MIN_CONFIDENCE" => "",
1044+
"PR_LENS_BLOCK_MIN_CONFIDENCE" => nil
1045+
},
1046+
"ruby",
1047+
script,
1048+
"meta-review",
1049+
"--artifact-root",
1050+
dir,
1051+
"--output",
1052+
output
1053+
)
1054+
1055+
assert status.success?, "#{stdout}\n#{stderr}"
1056+
result = JSON.parse(File.read(output))
1057+
assert_equal EvalOpsPrLensReview::DEFAULT_BLOCK_MIN_CONFIDENCE, result.fetch("block_min_confidence")
1058+
end
1059+
end
1060+
9591061
private
9601062

9611063
def capture_warnings

0 commit comments

Comments
 (0)