Skip to content

Commit 7a915fb

Browse files
authored
fix: check the author of every pull request commit (#332)
1 parent 63e0608 commit 7a915fb

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

‎main.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,36 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
839839
return results
840840

841841

842+
def run_pr_author_checks(
843+
flags: list[str], pr_commits: list[Commit]
844+
) -> list[ScopeResult]:
845+
"""Run the author checks on every author of the pull request's commits.
846+
847+
Checking only the branch tip let an earlier commit by another author,
848+
say a GitHub noreply address that ``author_email_pattern`` forbids, go
849+
through. Each distinct author is checked once, on the newest commit
850+
that carries it, so a one-person pull request still reports one scope
851+
per check. A commit the clone lacks is fetched without its history.
852+
853+
Returns ``[]`` when no author can be read, or a commit cannot be had
854+
even by fetching it, so the caller falls back to the branch tip as before.
855+
"""
856+
newest: dict[tuple[str, str], str] = {}
857+
for sha, _msg in pr_commits:
858+
author = API_AUTHORS.get(sha) or (_local_author(sha) if sha else None)
859+
if author:
860+
newest.pop(author, None)
861+
newest[author] = sha
862+
revs = [sha for sha in newest.values() if _rev_resolves(sha) or _fetch_commit(sha)]
863+
if len(revs) != len(newest):
864+
return []
865+
return [
866+
check_scope(CHECK_LABELS[flag], [flag, "--rev", sha], sha=sha)
867+
for flag in flags
868+
for sha in revs
869+
]
870+
871+
842872
def check_empty_message(
843873
label: str, sha: str, env: dict[str, str] | None
844874
) -> ScopeResult:
@@ -895,7 +925,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
895925
event; with only ``ai-label`` set it is read for the label, and no
896926
scope is reported)
897927
3. Individual PR commit messages (when ``message: true`` and in a PR event)
898-
4. All remaining checks (branch, author name/email, etc.)
928+
4. Author checks on every PR commit's author, then the remaining checks
929+
(branch; outside a PR, the HEAD commit and its author)
899930
900931
Outside of a PR event all enabled checks are handed to the CLI at once.
901932
"""
@@ -922,6 +953,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
922953
PR_BODY_AI_TOOLS[:] = scope.ai_tools
923954

924955
# ---- 3. Commit message checks -----------------------------------------
956+
pr_commits: list[Commit] | None = None
925957
if MESSAGE_ENABLED:
926958
pr_commits = get_pr_commit_messages()
927959
if pr_commits:
@@ -943,6 +975,14 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
943975
results.append(check_scope("Commit message", ["--message"], sha=head_sha()))
944976
args = [a for a in args if a != "--message"]
945977
rev = None
978+
author_flags = [flag for flag in args if flag in AUTHOR_FLAGS]
979+
if is_pr_event() and author_flags:
980+
if pr_commits is None:
981+
pr_commits = get_pr_commit_messages()
982+
author_scopes = run_pr_author_checks(author_flags, pr_commits)
983+
if author_scopes:
984+
results.extend(author_scopes)
985+
args = [a for a in args if a not in AUTHOR_FLAGS]
946986
if is_pr_event() and any(flag in AUTHOR_FLAGS for flag in args):
947987
rev = pr_head_rev()
948988
if rev is None:

‎main_test.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4110,6 +4110,109 @@ def test_a_missing_pygithub_warns(self):
41104110
unittest.main()
41114111

41124112

4113+
class TestPrAuthorChecksEveryCommit(unittest.TestCase):
4114+
"""The author checks read every PR commit's author, not only the tip."""
4115+
4116+
def setUp(self):
4117+
main.API_AUTHORS.clear()
4118+
self.addCleanup(main.API_AUTHORS.clear)
4119+
4120+
def _run(self, commits, authors, resolves=True, message=False):
4121+
commands: list[list[str]] = []
4122+
4123+
def run(command, **_kwargs):
4124+
commands.append(command)
4125+
if command[:2] == ["git", "rev-parse"]:
4126+
return MagicMock(returncode=0 if resolves else 1, stdout="")
4127+
if command[:2] == ["git", "fetch"]:
4128+
return MagicMock(returncode=1, stdout="")
4129+
if "--message" in command:
4130+
return MagicMock(
4131+
returncode=0, stdout=json_output(make_check("message"))
4132+
)
4133+
check = command[3].lstrip("-").replace("-", "_")
4134+
sha = command[-1]
4135+
status = "fail" if "noreply" in authors[sha][1] else "pass"
4136+
return MagicMock(
4137+
returncode=0,
4138+
stdout=json_output(make_check(check, status=status, value=sha)),
4139+
)
4140+
4141+
with (
4142+
patch("main.MESSAGE_ENABLED", message),
4143+
patch("main.BRANCH_ENABLED", False),
4144+
patch("main.AUTHOR_NAME_ENABLED", False),
4145+
patch("main.AUTHOR_EMAIL_ENABLED", True),
4146+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}),
4147+
patch("main.get_pr_commit_messages", return_value=commits) as listed,
4148+
patch("main._local_author", side_effect=lambda sha: authors.get(sha)),
4149+
patch("main.get_pr_head_sha", return_value=commits[-1][0]),
4150+
patch("main.subprocess.run", side_effect=run),
4151+
patch("sys.stdout", io.StringIO()),
4152+
):
4153+
rc, results = main.run_commit_check()
4154+
self.listed_calls = listed.call_count
4155+
return rc, results, commands
4156+
4157+
def test_the_commits_are_listed_once_for_messages_and_authors(self):
4158+
commits = [("a1", "feat: one")]
4159+
_rc, results, _commands = self._run(
4160+
commits, {"a1": ("Dev", "dev@example.org")}, message=True
4161+
)
4162+
self.assertEqual(self.listed_calls, 1)
4163+
self.assertEqual([s.label for s in results], ["Commit 1/1", "Author email"])
4164+
4165+
def test_a_commit_whose_author_cannot_be_read_is_passed_over(self):
4166+
commits = [("a1", "feat: one"), ("b2", "feat: two")]
4167+
_rc, results, _commands = self._run(commits, {"b2": ("Dev", "dev@example.org")})
4168+
self.assertEqual([(s.label, s.sha) for s in results], [("Author email", "b2")])
4169+
4170+
def test_an_earlier_commit_by_another_author_is_checked(self):
4171+
"""FreeBSD atf forbids noreply emails; the tip alone hid this one."""
4172+
commits = [("a1", "feat: one"), ("b2", "feat: two")]
4173+
authors = {
4174+
"a1": ("Dev", "dev@users.noreply.github.com"),
4175+
"b2": ("Dev", "dev@example.org"),
4176+
}
4177+
rc, results, commands = self._run(commits, authors)
4178+
self.assertEqual(rc, 1)
4179+
self.assertEqual(
4180+
[(s.label, s.sha, s.status) for s in results],
4181+
[("Author email", "a1", "fail"), ("Author email", "b2", "pass")],
4182+
)
4183+
self.assertIn(
4184+
["commit-check", "--format", "json", "--author-email", "--rev", "a1"],
4185+
commands,
4186+
)
4187+
4188+
def test_one_author_is_checked_once_on_the_newest_commit(self):
4189+
commits = [("a1", "feat: one"), ("b2", "feat: two"), ("c3", "feat: three")]
4190+
same = ("Dev", "dev@example.org")
4191+
rc, results, _commands = self._run(
4192+
commits, dict.fromkeys(("a1", "b2", "c3"), same)
4193+
)
4194+
self.assertEqual(rc, 0)
4195+
self.assertEqual([(s.label, s.sha) for s in results], [("Author email", "c3")])
4196+
4197+
def test_an_api_listed_author_is_used(self):
4198+
main.API_AUTHORS["a1"] = ("Dev", "dev@users.noreply.github.com")
4199+
_rc, results, _ = self._run(
4200+
[("a1", "feat: one")], {"a1": ("Dev", "dev@users.noreply.github.com")}
4201+
)
4202+
self.assertEqual([(s.sha, s.status) for s in results], [("a1", "fail")])
4203+
4204+
def test_a_commit_that_cannot_be_had_falls_back_to_the_tip(self):
4205+
"""Neither in the clone nor fetchable: the tip path decides (here: skip)."""
4206+
commits = [("a1", "feat: one")]
4207+
_rc, results, commands = self._run(
4208+
commits, {"a1": ("Dev", "dev@example.org")}, resolves=False
4209+
)
4210+
self.assertEqual(
4211+
[(s.label, s.status) for s in results], [("Author email", "skip")]
4212+
)
4213+
self.assertFalse([c for c in commands if c[0] == "commit-check"], commands)
4214+
4215+
41134216
class TestBooleanInputs(unittest.TestCase):
41144217
"""A boolean input is true or false; anything else fails the step."""
41154218

0 commit comments

Comments
 (0)