Skip to content

Commit 65e21d9

Browse files
committed
Fix PR lens publication edge cases
1 parent 124e5e4 commit 65e21d9

2 files changed

Lines changed: 155 additions & 36 deletions

File tree

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

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -429,8 +429,15 @@ def dispatch_requested_reviews(owner:, reviewer:, limit:, dry_run:, target_url:,
429429
summary
430430
end
431431

432+
def gh_api_paginated_json(*args, input: nil, token: ENV["GH_TOKEN"])
433+
raw = gh_api("--paginate", "--slurp", *args, input: input, token: token)
434+
return [] if raw.strip.empty?
435+
436+
JSON.parse(raw)
437+
end
438+
432439
def pr_files_metadata(repo:, pr:)
433-
gh_api_json("repos/#{repo}/pulls/#{pr}/files?per_page=100")
440+
Array(gh_api_paginated_json("repos/#{repo}/pulls/#{pr}/files?per_page=100")).flat_map { |page| Array(page) }
434441
end
435442

436443
# Parse a single file's unified-diff patch (as returned by the GitHub files API)
@@ -772,7 +779,7 @@ def pr_metadata(repo:, pr:)
772779
end
773780

774781
def pr_file_summary(repo:, pr:)
775-
files = gh_api_json("repos/#{repo}/pulls/#{pr}/files?per_page=100")
782+
files = pr_files_metadata(repo: repo, pr: pr)
776783
files.map do |file|
777784
[
778785
file.fetch("status"),
@@ -1331,8 +1338,8 @@ def marker_comment_ids(repo:, pr:)
13311338
raw.lines.map(&:strip).reject(&:empty?)
13321339
end
13331340

1334-
def delete_marker_comments(repo:, pr:)
1335-
marker_comment_ids(repo: repo, pr: pr).each do |id|
1341+
def delete_marker_comments(repo:, pr:, ids: nil)
1342+
Array(ids || marker_comment_ids(repo: repo, pr: pr)).each do |id|
13361343
gh_api("--method", "DELETE", "repos/#{repo}/issues/comments/#{id}")
13371344
end
13381345
end
@@ -1350,22 +1357,22 @@ def marker_review_comment_ids(repo:, pr:)
13501357
raw.lines.map(&:strip).reject(&:empty?)
13511358
end
13521359

1353-
def delete_marker_review_comments(repo:, pr:)
1354-
marker_review_comment_ids(repo: repo, pr: pr).each do |id|
1360+
def delete_marker_review_comments(repo:, pr:, ids: nil)
1361+
Array(ids || marker_review_comment_ids(repo: repo, pr: pr)).each do |id|
13551362
gh_api("--method", "DELETE", "repos/#{repo}/pulls/comments/#{id}")
13561363
end
13571364
end
13581365

13591366
def post_summary_comment(repo:, pr:, body:)
1360-
gh_api(
1367+
gh_api_json(
13611368
"--method", "POST", "repos/#{repo}/issues/#{pr}/comments",
13621369
input: JSON.generate(body: body)
13631370
)
13641371
end
13651372

13661373
def post_inline_comment(repo:, pr:, head_sha:, finding:)
13671374
location = finding.fetch("code_location")
1368-
gh_api(
1375+
gh_api_json(
13691376
"--method", "POST", "repos/#{repo}/pulls/#{pr}/comments",
13701377
input: JSON.generate(
13711378
commit_id: head_sha,
@@ -1385,18 +1392,38 @@ def clear_prior_publication(repo:, pr:)
13851392
delete_marker_review_comments(repo: repo, pr: pr)
13861393
end
13871394

1395+
def rollback_publication(repo:, pr:, summary_comment_id:, inline_comment_ids:)
1396+
delete_marker_review_comments(repo: repo, pr: pr, ids: inline_comment_ids.reverse)
1397+
delete_marker_comments(repo: repo, pr: pr, ids: [summary_comment_id].compact)
1398+
rescue StandardError => e
1399+
warn "pr-lens: failed to roll back partial publication for #{repo}##{pr}: #{e.message.lines.first.to_s.strip}"
1400+
end
1401+
13881402
# Publish findings as a marker summary issue comment plus inline PR comments
13891403
# anchored to each anchorable finding's code_location. Findings whose line is
13901404
# 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.
1405+
# summary body. Prior marker comments are deleted only after the replacement
1406+
# publication succeeds so a partial API failure does not leave a misleading
1407+
# summary or erase the prior review.
13921408
def publish_review(repo:, pr:, head_sha:, inline_findings:, summary_findings:, comment_min_confidence:, target_url:)
1393-
clear_prior_publication(repo: repo, pr: pr)
1394-
return if inline_findings.empty? && summary_findings.empty?
1409+
if inline_findings.empty? && summary_findings.empty?
1410+
clear_prior_publication(repo: repo, pr: pr)
1411+
return
1412+
end
13951413

13961414
inline_to_publish = inline_findings.first(MAX_FINDINGS_PER_COMMENT)
13971415
overflow_inline_findings = inline_findings.drop(MAX_FINDINGS_PER_COMMENT)
1416+
prior_summary_ids = marker_comment_ids(repo: repo, pr: pr)
1417+
prior_inline_ids = marker_review_comment_ids(repo: repo, pr: pr)
1418+
published_inline_ids = []
1419+
published_summary_id = nil
13981420

1399-
post_summary_comment(
1421+
inline_to_publish.each do |finding|
1422+
response = post_inline_comment(repo: repo, pr: pr, head_sha: head_sha, finding: finding)
1423+
published_inline_ids << response["id"] if response && response["id"]
1424+
end
1425+
1426+
published_summary_id = post_summary_comment(
14001427
repo: repo,
14011428
pr: pr,
14021429
body: review_summary_body(
@@ -1409,10 +1436,17 @@ def publish_review(repo:, pr:, head_sha:, inline_findings:, summary_findings:, c
14091436
target_url: target_url
14101437
)
14111438
)
1412-
1413-
inline_to_publish.each do |finding|
1414-
post_inline_comment(repo: repo, pr: pr, head_sha: head_sha, finding: finding)
1415-
end
1439+
published_summary_id = published_summary_id["id"] if published_summary_id
1440+
delete_marker_comments(repo: repo, pr: pr, ids: prior_summary_ids)
1441+
delete_marker_review_comments(repo: repo, pr: pr, ids: prior_inline_ids)
1442+
rescue StandardError
1443+
rollback_publication(
1444+
repo: repo,
1445+
pr: pr,
1446+
summary_comment_id: published_summary_id,
1447+
inline_comment_ids: published_inline_ids
1448+
)
1449+
raise
14161450
end
14171451

14181452
def blocking_findings(findings, block_min_confidence:)

test/evalops_pr_lens_review_test.rb

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,22 @@ def test_addable_lines_by_path_indexes_each_file_patch
754754
assert_empty map.fetch("infra/no_patch.bin")
755755
end
756756

757+
def test_pr_files_metadata_paginates_all_pages
758+
calls = []
759+
pages = [
760+
[{ "filename" => "infra/main.tf" }],
761+
[{ "filename" => "infra/extra.tf" }]
762+
]
763+
764+
EvalOpsPrLensReview.stub(:gh_api, ->(*args, **_kwargs) { calls << args; JSON.generate(pages) }) do
765+
files = EvalOpsPrLensReview.pr_files_metadata(repo: "evalops/deploy", pr: 1)
766+
767+
assert_equal ["infra/main.tf", "infra/extra.tf"], files.map { |file| file.fetch("filename") }
768+
assert_includes calls.fetch(0), "--paginate"
769+
assert_includes calls.fetch(0), "--slurp"
770+
end
771+
end
772+
757773
def test_finding_inline_anchorable_only_when_line_in_diff
758774
addable = { "infra/main.tf" => [22, 23].to_set }
759775
in_diff = finding("Anchorable", 0.9, 1, "infra/main.tf", 22)
@@ -896,11 +912,21 @@ def test_meta_review_green_status_states_coverage_when_only_low_confidence
896912
end
897913
end
898914

899-
def test_publish_review_posts_summary_comment_and_inline_comments_idempotently
915+
def test_publish_review_posts_inline_comments_before_summary_and_clears_prior_on_success
900916
api_calls = []
901917
fake_api = lambda do |*args, **kwargs|
902918
api_calls << { args: args, input: kwargs[:input] }
903-
""
919+
if args.include?("repos/evalops/deploy/issues/10/comments") && !args.include?("--method")
920+
"111\n"
921+
elsif args.include?("repos/evalops/deploy/pulls/10/comments") && !args.include?("--method")
922+
"222\n333\n"
923+
elsif args.include?("repos/evalops/deploy/pulls/10/comments")
924+
JSON.generate("id" => 444)
925+
elsif args.include?("repos/evalops/deploy/issues/10/comments")
926+
JSON.generate("id" => 555)
927+
else
928+
""
929+
end
904930
end
905931

906932
inline = [
@@ -910,38 +936,93 @@ def test_publish_review_posts_summary_comment_and_inline_comments_idempotently
910936
)
911937
]
912938

913-
EvalOpsPrLensReview.stub(:clear_prior_publication, ->(**_kwargs) { api_calls << { clear: true } }) do
914-
EvalOpsPrLensReview.stub(:gh_api, fake_api) do
915-
EvalOpsPrLensReview.publish_review(
916-
repo: "evalops/deploy",
917-
pr: 10,
918-
head_sha: "abc123",
919-
inline_findings: inline,
920-
summary_findings: [],
921-
comment_min_confidence: 0.55,
922-
target_url: "https://github.com/evalops/.github/actions/runs/1"
923-
)
924-
end
939+
EvalOpsPrLensReview.stub(:gh_api, fake_api) do
940+
EvalOpsPrLensReview.publish_review(
941+
repo: "evalops/deploy",
942+
pr: 10,
943+
head_sha: "abc123",
944+
inline_findings: inline,
945+
summary_findings: [],
946+
comment_min_confidence: 0.55,
947+
target_url: "https://github.com/evalops/.github/actions/runs/1"
948+
)
925949
end
926950

927-
# Prior publication is cleared before posting (idempotency).
928-
assert_equal({ clear: true }, api_calls.fetch(0))
929-
summary_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments") }
951+
summary_call = api_calls.find do |call|
952+
Array(call[:args]).include?("--method") && Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments")
953+
end
930954
assert summary_call
931955
summary_payload = JSON.parse(summary_call.fetch(:input))
932956
assert_includes summary_payload.fetch("body"), EvalOpsPrLensReview::MARKER
933957

934-
comment_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments") }
958+
comment_call = api_calls.find do |call|
959+
Array(call[:args]).include?("--method") && Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments")
960+
end
935961
assert comment_call
936962
comment = JSON.parse(comment_call.fetch(:input))
937963
assert_equal "abc123", comment.fetch("commit_id")
938964
assert_equal "infra/main.tf", comment.fetch("path")
939965
assert_equal 22, comment.fetch("line")
940966
assert_equal "RIGHT", comment.fetch("side")
941967
assert_includes comment.fetch("body"), EvalOpsPrLensReview::MARKER
968+
assert_operator api_calls.index(comment_call), :<, api_calls.index(summary_call)
969+
assert(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/comments/111") })
970+
assert(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/comments/222") })
971+
assert(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/comments/333") })
942972
refute(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/reviews") })
943973
end
944974

975+
def test_publish_review_rolls_back_partial_inline_publication_without_deleting_prior_comments
976+
api_calls = []
977+
inline_posts = 0
978+
inline = 2.times.map do |index|
979+
finding("Inline #{index}", 0.9, 1, "infra/main.tf", index + 1).merge(
980+
"lens" => "iam-blast-radius",
981+
"check_id" => "evalops-pr-lens/iam-blast-radius"
982+
)
983+
end
984+
985+
fake_api = lambda do |*args, **kwargs|
986+
api_calls << { args: args, input: kwargs[:input] }
987+
if args.include?("repos/evalops/deploy/issues/10/comments") && !args.include?("--method")
988+
"111\n"
989+
elsif args.include?("repos/evalops/deploy/pulls/10/comments") && !args.include?("--method")
990+
"222\n"
991+
elsif args.include?("repos/evalops/deploy/pulls/10/comments")
992+
inline_posts += 1
993+
raise "inline publish failed" if inline_posts == 2
994+
995+
JSON.generate("id" => 444)
996+
elsif args.include?("repos/evalops/deploy/issues/10/comments")
997+
JSON.generate("id" => 555)
998+
else
999+
""
1000+
end
1001+
end
1002+
1003+
error = assert_raises(RuntimeError) do
1004+
EvalOpsPrLensReview.stub(:gh_api, fake_api) do
1005+
EvalOpsPrLensReview.publish_review(
1006+
repo: "evalops/deploy",
1007+
pr: 10,
1008+
head_sha: "abc123",
1009+
inline_findings: inline,
1010+
summary_findings: [],
1011+
comment_min_confidence: 0.55,
1012+
target_url: "https://github.com/evalops/.github/actions/runs/1"
1013+
)
1014+
end
1015+
end
1016+
1017+
assert_equal "inline publish failed", error.message
1018+
refute(api_calls.any? do |call|
1019+
Array(call[:args]).include?("--method") && Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments")
1020+
end)
1021+
assert(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/comments/444") })
1022+
refute(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/comments/111") })
1023+
refute(api_calls.any? { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/comments/222") })
1024+
end
1025+
9451026
def test_publish_review_moves_inline_overflow_into_summary_comment
9461027
api_calls = []
9471028
fake_api = lambda do |*args, **kwargs|
@@ -969,14 +1050,18 @@ def test_publish_review_moves_inline_overflow_into_summary_comment
9691050
end
9701051
end
9711052

972-
summary_call = api_calls.find { |call| Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments") }
1053+
summary_call = api_calls.find do |call|
1054+
Array(call[:args]).include?("--method") && Array(call[:args]).include?("repos/evalops/deploy/issues/10/comments")
1055+
end
9731056
assert summary_call
9741057
summary_body = JSON.parse(summary_call.fetch(:input)).fetch("body")
9751058
assert_includes summary_body, "#{EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT} anchored inline below."
9761059
assert_includes summary_body, "Additional diff findings"
9771060
assert_includes summary_body, "`infra/main.tf:13`"
9781061

979-
inline_posts = api_calls.count { |call| Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments") }
1062+
inline_posts = api_calls.count do |call|
1063+
Array(call[:args]).include?("--method") && Array(call[:args]).include?("repos/evalops/deploy/pulls/10/comments")
1064+
end
9801065
assert_equal EvalOpsPrLensReview::MAX_FINDINGS_PER_COMMENT, inline_posts
9811066
end
9821067

0 commit comments

Comments
 (0)