Skip to content

Commit 21674f1

Browse files
authored
fix: keep the PR comment under GitHub's size limit (#338)
1 parent af141a7 commit 21674f1

2 files changed

Lines changed: 145 additions & 4 deletions

File tree

‎main.py‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,7 +2044,25 @@ def _report_footer() -> str:
20442044
)
20452045

20462046

2047-
def render_report(results: list[ScopeResult]) -> str:
2047+
def _run_url() -> str:
2048+
"""Link to this workflow run, whose page holds the job summary."""
2049+
repository = os.getenv("GITHUB_REPOSITORY", "")
2050+
run_id = os.getenv("GITHUB_RUN_ID", "")
2051+
if not (repository and run_id):
2052+
return ""
2053+
server = os.getenv("GITHUB_SERVER_URL") or "https://github.com"
2054+
return f"{server.rstrip('/')}/{repository}/actions/runs/{run_id}"
2055+
2056+
2057+
def _elsewhere(what: str) -> str:
2058+
"""Point to where the parts a shortened comment left out can be read."""
2059+
where = "job summary" if _summary_written() else "step log"
2060+
url = _run_url()
2061+
place = f"[{where}]({url})" if url else where
2062+
return f"{what} are in the {place} of this run."
2063+
2064+
2065+
def render_report(results: list[ScopeResult], detail: str = "full") -> str:
20482066
"""Render the Markdown report shared by the job summary and PR comment.
20492067
20502068
Opens with the hidden marker and a heading that is the verdict — the
@@ -2053,6 +2071,10 @@ def render_report(results: list[ScopeResult]) -> str:
20532071
listed any rule under ``warn``, the folded "How to fix", and the folded
20542072
listing of every scope.
20552073
2074+
``detail`` is for a PR comment that would not fit (``render_pr_comment``):
2075+
``"short"`` leaves out the listing, ``"headline"`` everything below the
2076+
group lines, and either says where to read what it left out.
2077+
20562078
A warned scope counts toward "passed", never toward "failed": it ran,
20572079
found something, and is reported in full, but the config asked for it to
20582080
be surfaced rather than enforced.
@@ -2080,14 +2102,22 @@ def render_report(results: list[ScopeResult]) -> str:
20802102
lines.extend([_group_lines(results), ""])
20812103
if skipped and _skip_reasons(results):
20822104
lines.extend([_skip_reasons(results), ""])
2105+
if detail == "headline":
2106+
what = "The findings and how to fix them" if failed or warned else "All checks"
2107+
lines.extend([_elsewhere(what), "", _report_footer()])
2108+
return "\n".join(lines)
20832109
if failed:
20842110
lines.extend([*_diff_block(results), ""])
20852111
if warned:
20862112
lines.extend(["**Warnings**", "", *_diff_block(results, "warn"), ""])
20872113
if failed:
20882114
how = _how_to_fix(results)
20892115
lines.extend([*how, ""] if how else [])
2090-
lines.extend([*_all_checks(results), "", _report_footer()])
2116+
if detail == "short":
2117+
lines.extend([_elsewhere(f"All {_plural(total, 'check')}"), ""])
2118+
else:
2119+
lines.extend([*_all_checks(results), ""])
2120+
lines.append(_report_footer())
20912121
return "\n".join(lines)
20922122

20932123

@@ -2096,9 +2126,31 @@ def render_job_summary(results: list[ScopeResult]) -> str:
20962126
return render_report(results)
20972127

20982128

2129+
#: The longest comment GitHub accepts, in UTF-16 code units.
2130+
COMMENT_LIMIT = 65536
2131+
2132+
2133+
def _comment_length(body: str) -> int:
2134+
"""Length of a comment as GitHub counts it: in UTF-16 code units."""
2135+
return len(body.encode("utf-16-le")) // 2
2136+
2137+
20992138
def render_pr_comment(results: list[ScopeResult]) -> str:
2100-
"""Create the Markdown body for the PR comment (same report as summary)."""
2101-
return render_report(results)
2139+
"""Create the Markdown body for the PR comment (same report as summary).
2140+
2141+
The report grows with the pull request: every failing commit's message
2142+
is quoted in the diff, the prompt and the listing. A few dozen failing
2143+
commits ran past GitHub's limit, the API refused the comment, and an
2144+
earlier one, perhaps saying "passed", stayed on the pull request. So a
2145+
report that does not fit drops the listing, then everything under the
2146+
group lines, and points to the run for the rest. The job summary, with
2147+
its far higher limit, keeps the full report.
2148+
"""
2149+
for detail in ("full", "short"):
2150+
body = render_report(results, detail)
2151+
if _comment_length(body) <= COMMENT_LIMIT:
2152+
return body
2153+
return render_report(results, "headline")
21022154

21032155

21042156
# ---------------------------------------------------------------------------

‎main_test.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,6 +2732,95 @@ def test_failure_matches_job_summary(self):
27322732
self.assertIn("```diff\n@@ Commit 1/1 @@\n", comment)
27332733

27342734

2735+
class TestPrCommentSizeLimit(unittest.TestCase):
2736+
"""A report too long for a comment gives up its longest parts first."""
2737+
2738+
RUN_URL = "https://github.com/o/r/actions/runs/42"
2739+
2740+
def setUp(self):
2741+
self.RUN = {
2742+
"GITHUB_REPOSITORY": "o/r",
2743+
"GITHUB_RUN_ID": "42",
2744+
"GITHUB_SERVER_URL": "https://github.com",
2745+
}
2746+
2747+
def _failing_commits(self, count: int) -> list[main.ScopeResult]:
2748+
body = "\n".join(
2749+
f"Line {i} of a body that explains the change." for i in range(8)
2750+
)
2751+
return [
2752+
main.ScopeResult(
2753+
label=f"Commit {i}/{count}",
2754+
sha=f"{i:040x}",
2755+
checks=[
2756+
make_check(
2757+
"require_signed_off_by",
2758+
"fail",
2759+
"CC012",
2760+
value=f"Update module {i}\n\n{body}",
2761+
error="Signed-off-by trailer not found in the commit message",
2762+
fix=f"Update module {i}\n\n{body}\n\nSigned-off-by: A <a@b.c>",
2763+
)
2764+
],
2765+
)
2766+
for i in range(1, count + 1)
2767+
]
2768+
2769+
def test_a_report_that_fits_is_the_job_summary(self):
2770+
results = self._failing_commits(3)
2771+
self.assertEqual(
2772+
main.render_pr_comment(results), main.render_job_summary(results)
2773+
)
2774+
2775+
def test_dozens_of_failing_commits_still_fit(self):
2776+
results = self._failing_commits(60)
2777+
self.assertGreater(len(main.render_job_summary(results)), main.COMMENT_LIMIT)
2778+
with (
2779+
patch.dict(os.environ, self.RUN),
2780+
patch("main.JOB_SUMMARY_ENABLED", True),
2781+
patch("main.GITHUB_STEP_SUMMARY", "/tmp/summary.md"),
2782+
):
2783+
comment = main.render_pr_comment(results)
2784+
self.assertLessEqual(main._comment_length(comment), main.COMMENT_LIMIT)
2785+
self.assertTrue(comment.startswith(main.COMMENT_MARKER))
2786+
self.assertIn("Commit Check failed", comment)
2787+
self.assertNotIn("<summary>All 60 checks</summary>", comment)
2788+
self.assertIn(
2789+
f"All 60 checks are in the [job summary]({self.RUN_URL}) of this run.",
2790+
comment,
2791+
)
2792+
2793+
def test_the_diff_goes_too_when_the_listing_was_not_enough(self):
2794+
results = self._failing_commits(2)
2795+
with (
2796+
patch.dict(os.environ, self.RUN),
2797+
patch("main.JOB_SUMMARY_ENABLED", False),
2798+
):
2799+
short = main.render_report(results, "short")
2800+
with patch("main.COMMENT_LIMIT", len(short) - 1):
2801+
comment = main.render_pr_comment(results)
2802+
self.assertNotIn("```diff", comment)
2803+
self.assertNotIn("How to fix</b>", comment)
2804+
self.assertIn(
2805+
"The findings and how to fix them are in the "
2806+
f"[step log]({self.RUN_URL}) of this run.",
2807+
comment,
2808+
)
2809+
self.assertIn("Commits: 2 of 2 failed", comment)
2810+
2811+
def test_a_passing_headline_names_no_findings(self):
2812+
env = {k: v for k, v in os.environ.items() if k != "GITHUB_RUN_ID"}
2813+
with (
2814+
patch.dict(os.environ, env, clear=True),
2815+
patch("main.JOB_SUMMARY_ENABLED", False),
2816+
):
2817+
body = main.render_report([pass_scope("Branch")], "headline")
2818+
self.assertIn("All checks are in the step log of this run.", body)
2819+
2820+
def test_length_is_counted_as_github_counts_it(self):
2821+
self.assertEqual(main._comment_length("a🤖"), 3)
2822+
2823+
27352824
class TestUnparsableScopeInTheReport(unittest.TestCase):
27362825
"""A CLI response that is not JSON is a failure with no rule to link."""
27372826

0 commit comments

Comments
 (0)