Skip to content

Commit f723625

Browse files
committed
Gracefully handle "diff too large" errors.
1 parent f75c061 commit f723625

File tree

6 files changed

+255
-26
lines changed

6 files changed

+255
-26
lines changed

coverage_comment/github.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ class NoArtifact(Exception):
2828
pass
2929

3030

31+
class CannotGetDiff(Exception):
32+
"""Raised when the diff cannot be fetched from GitHub."""
33+
34+
pass
35+
36+
3137
@dataclasses.dataclass
3238
class RepositoryInfo:
3339
default_branch: str
@@ -289,11 +295,19 @@ def get_pr_diff(github: github_client.GitHub, repository: str, pr_number: int) -
289295
"""
290296
Get the diff of a pull request.
291297
"""
292-
return (
293-
github.repos(repository)
294-
.pulls(pr_number)
295-
.get(headers={"Accept": "application/vnd.github.v3.diff"}, text=True)
296-
)
298+
try:
299+
return (
300+
github.repos(repository)
301+
.pulls(pr_number)
302+
.get(headers={"Accept": "application/vnd.github.v3.diff"}, text=True)
303+
)
304+
except github_client.ApiError as exc:
305+
if _is_too_large_error(exc):
306+
raise CannotGetDiff(
307+
"The diff for this PR is too large to be retrieved from GitHub's API "
308+
"(maximum 300 files). Diff coverage is not available for this PR."
309+
) from exc
310+
raise
297311

298312

299313
def get_branch_diff(
@@ -302,8 +316,31 @@ def get_branch_diff(
302316
"""
303317
Get the diff of branch.
304318
"""
305-
return (
306-
github.repos(repository)
307-
.compare(f"{base_branch}...{head_branch}")
308-
.get(headers={"Accept": "application/vnd.github.v3.diff"}, text=True)
309-
)
319+
try:
320+
return (
321+
github.repos(repository)
322+
.compare(f"{base_branch}...{head_branch}")
323+
.get(headers={"Accept": "application/vnd.github.v3.diff"}, text=True)
324+
)
325+
except github_client.ApiError as exc:
326+
if _is_too_large_error(exc):
327+
raise CannotGetDiff(
328+
"The diff for this branch is too large to be retrieved from GitHub's API "
329+
"(maximum 300 files). Diff coverage is not available for this branch."
330+
) from exc
331+
raise
332+
333+
334+
def _is_too_large_error(exc: github_client.ApiError) -> bool:
335+
"""
336+
Check if the error is a "too_large" error from GitHub API.
337+
338+
GitHub returns this error when the diff exceeds the maximum number of files (300).
339+
The error response body is JSON from GitHub's API.
340+
"""
341+
try:
342+
error_data: dict[str, Any] = json.loads(str(exc))
343+
errors: list[dict[str, Any]] = error_data.get("errors", [])
344+
return any(error.get("code") == "too_large" for error in errors)
345+
except (json.JSONDecodeError, TypeError):
346+
return False

coverage_comment/main.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,27 @@ def process_pr(
133133
)
134134
base_ref = config.GITHUB_BASE_REF or repo_info.default_branch
135135

136-
if config.GITHUB_BRANCH_NAME:
137-
diff = github.get_branch_diff(
138-
github=gh,
139-
repository=config.GITHUB_REPOSITORY,
140-
base_branch=base_ref,
141-
head_branch=config.GITHUB_BRANCH_NAME,
142-
)
143-
elif config.GITHUB_PR_NUMBER:
144-
diff = github.get_pr_diff(
145-
github=gh,
146-
repository=config.GITHUB_REPOSITORY,
147-
pr_number=config.GITHUB_PR_NUMBER,
148-
)
149-
else: # pragma: no cover
150-
raise Exception("Unreachable code")
136+
failure_msg: str | None = None
137+
try:
138+
if config.GITHUB_BRANCH_NAME:
139+
diff = github.get_branch_diff(
140+
github=gh,
141+
repository=config.GITHUB_REPOSITORY,
142+
base_branch=base_ref,
143+
head_branch=config.GITHUB_BRANCH_NAME,
144+
)
145+
elif config.GITHUB_PR_NUMBER:
146+
diff = github.get_pr_diff(
147+
github=gh,
148+
repository=config.GITHUB_REPOSITORY,
149+
pr_number=config.GITHUB_PR_NUMBER,
150+
)
151+
else: # pragma: no cover
152+
raise Exception("Unreachable code")
153+
except github.CannotGetDiff as exc:
154+
failure_msg = str(exc)
155+
log.warning(failure_msg, exc_info=True)
156+
diff = ""
151157

152158
added_lines = coverage_module.get_added_lines(diff=diff)
153159
diff_coverage = coverage_module.get_diff_coverage_info(
@@ -219,6 +225,7 @@ def process_pr(
219225
pr_targets_default_branch=pr_targets_default_branch,
220226
marker=marker,
221227
subproject_id=config.SUBPROJECT_ID,
228+
failure_msg=failure_msg,
222229
)
223230
# Same as above except `max_files` is None
224231
summary_comment = template.get_comment_markdown(
@@ -240,6 +247,7 @@ def process_pr(
240247
pr_targets_default_branch=pr_targets_default_branch,
241248
marker=marker,
242249
subproject_id=config.SUBPROJECT_ID,
250+
failure_msg=failure_msg,
243251
)
244252
except template.MissingMarker:
245253
log.error(

coverage_comment/template.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def get_comment_markdown(
141141
subproject_id: str | None = None,
142142
custom_template: str | None = None,
143143
pr_targets_default_branch: bool = True,
144+
failure_msg: str | None = None,
144145
):
145146
loader = CommentLoader(base_template=base_template, custom_template=custom_template)
146147
env = SandboxedEnvironment(loader=loader)
@@ -188,6 +189,7 @@ def get_comment_markdown(
188189
subproject_id=subproject_id,
189190
marker=marker,
190191
pr_targets_default_branch=pr_targets_default_branch,
192+
failure_msg=failure_msg,
191193
)
192194
except jinja2.exceptions.TemplateError as exc:
193195
raise TemplateError from exc

coverage_comment/template_files/comment.md.j2

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,25 @@
1919
{#- Coverage diff badge -#}
2020
{#- space #} {# space -#}
2121
{%- block diff_coverage_badge -%}
22+
{%- if failure_msg -%}
23+
<img title="Diff coverage unavailable" src="{{ 'PR Coverage' | generate_badge(message='N/A', color='grey') }}">
24+
25+
{%- else -%}
2226
{%- set text = (diff_coverage.total_percent_covered | pct) ~ " of the statement lines added by this PR are covered" -%}
2327
<img title="{{ text }}" src="{{ 'PR Coverage' | generate_badge(message=diff_coverage.total_percent_covered | pct(precision=0), color=diff_coverage.total_percent_covered | x100 | get_badge_color) }}">
2428

29+
{%- endif -%}
2530
{%- endblock diff_coverage_badge -%}
2631
{%- endblock coverage_badges -%}
2732

33+
{%- block diff_coverage_failure_message -%}
34+
{%- if failure_msg %}
35+
36+
> [!WARNING]
37+
> {{ failure_msg }}
38+
39+
{% endif -%}
40+
{%- endblock diff_coverage_failure_message -%}
2841

2942
{%- macro statements_badge(path, statements_count, previous_statements_count) -%}
3043
{% if previous_statements_count is not none -%}

tests/integration/test_github.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from coverage_comment import github
7+
from coverage_comment import github, github_client
88

99

1010
@pytest.mark.parametrize(
@@ -491,3 +491,99 @@ def test_get_branch_diff(gh, session):
491491
)
492492

493493
assert result == "diff --git a/foo.py b/foo.py..."
494+
495+
496+
def test_get_pr_diff__too_large(gh, session):
497+
error_response = {
498+
"message": "Sorry, the diff exceeded the maximum number of files (300).",
499+
"errors": [{"resource": "PullRequest", "field": "diff", "code": "too_large"}],
500+
"documentation_url": "https://docs.github.com/rest/pulls/pulls#list-pull-requests-files",
501+
"status": "406",
502+
}
503+
session.register(
504+
"GET",
505+
"/repos/foo/bar/pulls/123",
506+
headers={"Accept": "application/vnd.github.v3.diff"},
507+
)(json=error_response, status_code=406)
508+
509+
with pytest.raises(github.CannotGetDiff) as exc_info:
510+
github.get_pr_diff(github=gh, repository="foo/bar", pr_number=123)
511+
512+
assert "too large" in str(exc_info.value)
513+
assert "maximum 300 files" in str(exc_info.value)
514+
515+
516+
def test_get_branch_diff__too_large(gh, session):
517+
error_response = {
518+
"message": "Sorry, the diff exceeded the maximum number of files (300).",
519+
"errors": [{"resource": "PullRequest", "field": "diff", "code": "too_large"}],
520+
"documentation_url": "https://docs.github.com/rest/pulls/pulls#list-pull-requests-files",
521+
"status": "406",
522+
}
523+
session.register(
524+
"GET",
525+
"/repos/foo/bar/compare/main...feature",
526+
headers={"Accept": "application/vnd.github.v3.diff"},
527+
)(json=error_response, status_code=406)
528+
529+
with pytest.raises(github.CannotGetDiff) as exc_info:
530+
github.get_branch_diff(
531+
github=gh, repository="foo/bar", base_branch="main", head_branch="feature"
532+
)
533+
534+
assert "too large" in str(exc_info.value)
535+
assert "maximum 300 files" in str(exc_info.value)
536+
537+
538+
def test_get_pr_diff__other_error(gh, session):
539+
error_response = {"message": "Some other error", "errors": []}
540+
session.register(
541+
"GET",
542+
"/repos/foo/bar/pulls/123",
543+
headers={"Accept": "application/vnd.github.v3.diff"},
544+
)(json=error_response, status_code=500)
545+
546+
with pytest.raises(github_client.ApiError):
547+
github.get_pr_diff(github=gh, repository="foo/bar", pr_number=123)
548+
549+
550+
def test_get_branch_diff__other_error(gh, session):
551+
error_response = {"message": "Some other error", "errors": []}
552+
session.register(
553+
"GET",
554+
"/repos/foo/bar/compare/main...feature",
555+
headers={"Accept": "application/vnd.github.v3.diff"},
556+
)(json=error_response, status_code=500)
557+
558+
with pytest.raises(github_client.ApiError):
559+
github.get_branch_diff(
560+
github=gh, repository="foo/bar", base_branch="main", head_branch="feature"
561+
)
562+
563+
564+
@pytest.mark.parametrize(
565+
"error_str,expected",
566+
[
567+
# Valid JSON with too_large error
568+
('{"errors": [{"code": "too_large"}]}', True),
569+
# Valid JSON with too_large error and extra fields
570+
(
571+
'{"message": "Diff too large", "errors": [{"resource": "PR", "code": "too_large"}]}',
572+
True,
573+
),
574+
# Valid JSON without too_large error
575+
('{"errors": [{"code": "other"}]}', False),
576+
# Valid JSON with empty errors
577+
('{"errors": []}', False),
578+
# Valid JSON with no errors key
579+
('{"message": "error"}', False),
580+
# Non-JSON string (returns False, not a fallback match)
581+
("not valid json", False),
582+
# Empty string
583+
("", False),
584+
],
585+
)
586+
def test__is_too_large_error(error_str, expected):
587+
"""Test the _is_too_large_error helper function with various inputs."""
588+
exc = github_client.ApiError(error_str)
589+
assert github._is_too_large_error(exc) is expected

tests/integration/test_main.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,3 +927,76 @@ def test_action__workflow_run__post_comment(
927927
assert get_logs("INFO", "Comment file found in artifact, posting to PR")
928928
assert get_logs("INFO", "Comment posted in PR")
929929
assert summary_file.read_text() == ""
930+
931+
932+
def test_action__pull_request__diff_too_large(
933+
pull_request_config,
934+
session,
935+
in_integration_env,
936+
output_file,
937+
summary_file,
938+
git,
939+
get_logs,
940+
):
941+
"""Test that when the diff is too large, a warning is shown in the comment."""
942+
session.register("GET", "/repos/py-cov-action/foobar")(
943+
json={"default_branch": "main", "visibility": "public"}
944+
)
945+
# No existing badge in this test
946+
session.register("GET", "/repos/py-cov-action/foobar/contents/data.json")(
947+
status_code=404
948+
)
949+
950+
# Who am I
951+
session.register("GET", "/user")(json={"login": "foo"})
952+
# Are there already comments
953+
session.register("GET", "/repos/py-cov-action/foobar/issues/2/comments")(json=[])
954+
955+
comment = None
956+
957+
def checker(payload):
958+
body = payload["body"]
959+
assert "## Coverage report" in body
960+
nonlocal comment
961+
comment = body
962+
return True
963+
964+
# Post a new comment
965+
session.register(
966+
"POST", "/repos/py-cov-action/foobar/issues/2/comments", json=checker
967+
)(status_code=200)
968+
969+
# The diff is too large - returns 406 with error
970+
error_response = {
971+
"message": "Sorry, the diff exceeded the maximum number of files (300).",
972+
"errors": [{"resource": "PullRequest", "field": "diff", "code": "too_large"}],
973+
"documentation_url": "https://docs.github.com/rest/pulls/pulls#list-pull-requests-files",
974+
"status": "406",
975+
}
976+
session.register("GET", "/repos/py-cov-action/foobar/pulls/2")(
977+
json=error_response, status_code=406
978+
)
979+
980+
result = main.action(
981+
config=pull_request_config(
982+
GITHUB_OUTPUT=output_file, GITHUB_STEP_SUMMARY=summary_file
983+
),
984+
github_session=session,
985+
http_session=session,
986+
git=git,
987+
)
988+
assert result == 0
989+
990+
# Check that a warning was logged
991+
assert get_logs("WARNING", "too large")
992+
993+
# Comment was posted successfully, no fallback file should exist
994+
assert not pathlib.Path("python-coverage-comment-action.txt").exists()
995+
996+
# Check that the error message is in the comment
997+
assert "too large" in comment
998+
assert "maximum 300 files" in comment
999+
# Check that the warning block is in the comment
1000+
assert "[!WARNING]" in comment
1001+
# Check the N/A badge is shown for PR coverage (URL-encoded in badge URL)
1002+
assert "PR%20Coverage-N/A-grey" in comment

0 commit comments

Comments
 (0)