Skip to content

Commit 35cdfbd

Browse files
authored
Merge branch 'main' into fix/ai-author-diff-line
2 parents efb5c24 + 21674f1 commit 35cdfbd

2 files changed

Lines changed: 202 additions & 5 deletions

File tree

‎main.py‎

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,22 @@ def keep_ai_rules(scope: ScopeResult) -> ScopeResult:
502502
return skip_when_no_ai_rule_ran(scope)
503503

504504

505+
#: Rules about a commit rather than about a line of text: it has a body
506+
#: (CC011), a sign-off (CC012), an author that is no AI tool (CC017). A pull
507+
#: request title has neither body nor trailers nor an author of its own, so
508+
#: judged as a commit message it could never pass the first two, and the
509+
#: third would judge whatever git identity the runner has.
510+
COMMIT_ONLY_RULE_IDS = frozenset({"CC011", "CC012", "CC017"})
511+
512+
513+
def drop_commit_only_rules(scope: ScopeResult) -> ScopeResult:
514+
"""Narrow the PR title scope to the rules a one-line title can meet."""
515+
scope.checks = [
516+
c for c in scope.checks if c.get("rule_id") not in COMMIT_ONLY_RULE_IDS
517+
]
518+
return scope
519+
520+
505521
def get_pr_title() -> str | None:
506522
"""Read PR title from GitHub event payload."""
507523
if not is_pr_event():
@@ -972,7 +988,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
972988
if PR_TITLE_ENABLED and is_pr_event():
973989
pr_title = get_pr_title()
974990
if pr_title:
975-
results.append(check_scope("PR title", ["--message"], input_text=pr_title))
991+
scope = check_scope("PR title", ["--message"], input_text=pr_title)
992+
results.append(drop_commit_only_rules(scope))
976993

977994
# ---- 2. PR description ------------------------------------------------
978995
if (PR_BODY_AI_ATTRIBUTION_ENABLED or ai_label_name()) and is_pr_event():
@@ -2041,7 +2058,25 @@ def _report_footer() -> str:
20412058
)
20422059

20432060

2044-
def render_report(results: list[ScopeResult]) -> str:
2061+
def _run_url() -> str:
2062+
"""Link to this workflow run, whose page holds the job summary."""
2063+
repository = os.getenv("GITHUB_REPOSITORY", "")
2064+
run_id = os.getenv("GITHUB_RUN_ID", "")
2065+
if not (repository and run_id):
2066+
return ""
2067+
server = os.getenv("GITHUB_SERVER_URL") or "https://github.com"
2068+
return f"{server.rstrip('/')}/{repository}/actions/runs/{run_id}"
2069+
2070+
2071+
def _elsewhere(what: str) -> str:
2072+
"""Point to where the parts a shortened comment left out can be read."""
2073+
where = "job summary" if _summary_written() else "step log"
2074+
url = _run_url()
2075+
place = f"[{where}]({url})" if url else where
2076+
return f"{what} are in the {place} of this run."
2077+
2078+
2079+
def render_report(results: list[ScopeResult], detail: str = "full") -> str:
20452080
"""Render the Markdown report shared by the job summary and PR comment.
20462081
20472082
Opens with the hidden marker and a heading that is the verdict — the
@@ -2050,6 +2085,10 @@ def render_report(results: list[ScopeResult]) -> str:
20502085
listed any rule under ``warn``, the folded "How to fix", and the folded
20512086
listing of every scope.
20522087
2088+
``detail`` is for a PR comment that would not fit (``render_pr_comment``):
2089+
``"short"`` leaves out the listing, ``"headline"`` everything below the
2090+
group lines, and either says where to read what it left out.
2091+
20532092
A warned scope counts toward "passed", never toward "failed": it ran,
20542093
found something, and is reported in full, but the config asked for it to
20552094
be surfaced rather than enforced.
@@ -2077,14 +2116,22 @@ def render_report(results: list[ScopeResult]) -> str:
20772116
lines.extend([_group_lines(results), ""])
20782117
if skipped and _skip_reasons(results):
20792118
lines.extend([_skip_reasons(results), ""])
2119+
if detail == "headline":
2120+
what = "The findings and how to fix them" if failed or warned else "All checks"
2121+
lines.extend([_elsewhere(what), "", _report_footer()])
2122+
return "\n".join(lines)
20802123
if failed:
20812124
lines.extend([*_diff_block(results), ""])
20822125
if warned:
20832126
lines.extend(["**Warnings**", "", *_diff_block(results, "warn"), ""])
20842127
if failed:
20852128
how = _how_to_fix(results)
20862129
lines.extend([*how, ""] if how else [])
2087-
lines.extend([*_all_checks(results), "", _report_footer()])
2130+
if detail == "short":
2131+
lines.extend([_elsewhere(f"All {_plural(total, 'check')}"), ""])
2132+
else:
2133+
lines.extend([*_all_checks(results), ""])
2134+
lines.append(_report_footer())
20882135
return "\n".join(lines)
20892136

20902137

@@ -2093,9 +2140,31 @@ def render_job_summary(results: list[ScopeResult]) -> str:
20932140
return render_report(results)
20942141

20952142

2143+
#: The longest comment GitHub accepts, in UTF-16 code units.
2144+
COMMENT_LIMIT = 65536
2145+
2146+
2147+
def _comment_length(body: str) -> int:
2148+
"""Length of a comment as GitHub counts it: in UTF-16 code units."""
2149+
return len(body.encode("utf-16-le")) // 2
2150+
2151+
20962152
def render_pr_comment(results: list[ScopeResult]) -> str:
2097-
"""Create the Markdown body for the PR comment (same report as summary)."""
2098-
return render_report(results)
2153+
"""Create the Markdown body for the PR comment (same report as summary).
2154+
2155+
The report grows with the pull request: every failing commit's message
2156+
is quoted in the diff, the prompt and the listing. A few dozen failing
2157+
commits ran past GitHub's limit, the API refused the comment, and an
2158+
earlier one, perhaps saying "passed", stayed on the pull request. So a
2159+
report that does not fit drops the listing, then everything under the
2160+
group lines, and points to the run for the rest. The job summary, with
2161+
its far higher limit, keeps the full report.
2162+
"""
2163+
for detail in ("full", "short"):
2164+
body = render_report(results, detail)
2165+
if _comment_length(body) <= COMMENT_LIMIT:
2166+
return body
2167+
return render_report(results, "headline")
20992168

21002169

21012170
# ---------------------------------------------------------------------------

‎main_test.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,33 @@ def test_pr_title_check_runs_when_enabled(self):
10041004
"PR title", ["--message"], input_text="feat: a feature"
10051005
)
10061006

1007+
def test_pr_title_is_not_held_to_commit_only_rules(self):
1008+
# require_body and require_signed_off_by judge a commit; a title has
1009+
# no body or trailers, so it could never pass them.
1010+
title = main.ScopeResult(
1011+
label="PR title",
1012+
checks=[
1013+
make_check("message", "pass", "CC001", value="feat: add login"),
1014+
make_check("require_body", "fail", "CC011", value="feat: add login"),
1015+
make_check("require_signed_off_by", "fail", "CC012"),
1016+
make_check("ai_author", "fail", "CC017", value="Claude <x@y.z>"),
1017+
],
1018+
)
1019+
with (
1020+
patch("main.PR_TITLE_ENABLED", True),
1021+
patch("main.MESSAGE_ENABLED", False),
1022+
patch("main.BRANCH_ENABLED", False),
1023+
patch("main.AUTHOR_NAME_ENABLED", False),
1024+
patch("main.AUTHOR_EMAIL_ENABLED", False),
1025+
patch("main.is_pr_event", return_value=True),
1026+
patch("main.get_pr_title", return_value="feat: add login"),
1027+
patch("main.check_scope", return_value=title),
1028+
patch("main.run_other_checks", return_value=[]),
1029+
):
1030+
rc, results = main.run_commit_check()
1031+
self.assertEqual(rc, 0)
1032+
self.assertEqual([c["rule_id"] for c in results[0].checks], ["CC001"])
1033+
10071034
def test_pr_title_failure_propagates(self):
10081035
with (
10091036
patch("main.PR_TITLE_ENABLED", True),
@@ -2744,6 +2771,95 @@ def test_failure_matches_job_summary(self):
27442771
self.assertIn("```diff\n@@ Commit 1/1 @@\n", comment)
27452772

27462773

2774+
class TestPrCommentSizeLimit(unittest.TestCase):
2775+
"""A report too long for a comment gives up its longest parts first."""
2776+
2777+
RUN_URL = "https://github.com/o/r/actions/runs/42"
2778+
2779+
def setUp(self):
2780+
self.RUN = {
2781+
"GITHUB_REPOSITORY": "o/r",
2782+
"GITHUB_RUN_ID": "42",
2783+
"GITHUB_SERVER_URL": "https://github.com",
2784+
}
2785+
2786+
def _failing_commits(self, count: int) -> list[main.ScopeResult]:
2787+
body = "\n".join(
2788+
f"Line {i} of a body that explains the change." for i in range(8)
2789+
)
2790+
return [
2791+
main.ScopeResult(
2792+
label=f"Commit {i}/{count}",
2793+
sha=f"{i:040x}",
2794+
checks=[
2795+
make_check(
2796+
"require_signed_off_by",
2797+
"fail",
2798+
"CC012",
2799+
value=f"Update module {i}\n\n{body}",
2800+
error="Signed-off-by trailer not found in the commit message",
2801+
fix=f"Update module {i}\n\n{body}\n\nSigned-off-by: A <a@b.c>",
2802+
)
2803+
],
2804+
)
2805+
for i in range(1, count + 1)
2806+
]
2807+
2808+
def test_a_report_that_fits_is_the_job_summary(self):
2809+
results = self._failing_commits(3)
2810+
self.assertEqual(
2811+
main.render_pr_comment(results), main.render_job_summary(results)
2812+
)
2813+
2814+
def test_dozens_of_failing_commits_still_fit(self):
2815+
results = self._failing_commits(60)
2816+
self.assertGreater(len(main.render_job_summary(results)), main.COMMENT_LIMIT)
2817+
with (
2818+
patch.dict(os.environ, self.RUN),
2819+
patch("main.JOB_SUMMARY_ENABLED", True),
2820+
patch("main.GITHUB_STEP_SUMMARY", "/tmp/summary.md"),
2821+
):
2822+
comment = main.render_pr_comment(results)
2823+
self.assertLessEqual(main._comment_length(comment), main.COMMENT_LIMIT)
2824+
self.assertTrue(comment.startswith(main.COMMENT_MARKER))
2825+
self.assertIn("Commit Check failed", comment)
2826+
self.assertNotIn("<summary>All 60 checks</summary>", comment)
2827+
self.assertIn(
2828+
f"All 60 checks are in the [job summary]({self.RUN_URL}) of this run.",
2829+
comment,
2830+
)
2831+
2832+
def test_the_diff_goes_too_when_the_listing_was_not_enough(self):
2833+
results = self._failing_commits(2)
2834+
with (
2835+
patch.dict(os.environ, self.RUN),
2836+
patch("main.JOB_SUMMARY_ENABLED", False),
2837+
):
2838+
short = main.render_report(results, "short")
2839+
with patch("main.COMMENT_LIMIT", len(short) - 1):
2840+
comment = main.render_pr_comment(results)
2841+
self.assertNotIn("```diff", comment)
2842+
self.assertNotIn("How to fix</b>", comment)
2843+
self.assertIn(
2844+
"The findings and how to fix them are in the "
2845+
f"[step log]({self.RUN_URL}) of this run.",
2846+
comment,
2847+
)
2848+
self.assertIn("Commits: 2 of 2 failed", comment)
2849+
2850+
def test_a_passing_headline_names_no_findings(self):
2851+
env = {k: v for k, v in os.environ.items() if k != "GITHUB_RUN_ID"}
2852+
with (
2853+
patch.dict(os.environ, env, clear=True),
2854+
patch("main.JOB_SUMMARY_ENABLED", False),
2855+
):
2856+
body = main.render_report([pass_scope("Branch")], "headline")
2857+
self.assertIn("All checks are in the step log of this run.", body)
2858+
2859+
def test_length_is_counted_as_github_counts_it(self):
2860+
self.assertEqual(main._comment_length("a🤖"), 3)
2861+
2862+
27472863
class TestUnparsableScopeInTheReport(unittest.TestCase):
27482864
"""A CLI response that is not JSON is a failure with no rule to link."""
27492865

@@ -4261,6 +4377,18 @@ def test_failing_message(self):
42614377
main.render_report([scope]),
42624378
)
42634379

4380+
def test_a_title_meets_only_the_rules_a_title_can(self):
4381+
with open(self.config, "w", encoding="utf-8") as f:
4382+
f.write("[commit]\nrequire_body = true\nrequire_signed_off_by = true\n")
4383+
rc, data = self._run("feat: add login page")
4384+
failed = {c["rule_id"] for c in data["checks"] if c["status"] == "fail"}
4385+
self.assertEqual(rc, 1)
4386+
self.assertEqual(failed, {"CC011", "CC012"})
4387+
scope = main.drop_commit_only_rules(
4388+
main.ScopeResult(label="PR title", checks=data["checks"])
4389+
)
4390+
self.assertEqual(scope.status, "pass")
4391+
42644392

42654393
class TestStderrIsNotPartOfTheJson(unittest.TestCase):
42664394
"""stdout is the JSON; stderr is what the CLI has to say to a person.

0 commit comments

Comments
 (0)