Skip to content

Commit e778ad9

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/message-ai-attribution
# Conflicts: # main.py
2 parents 6973358 + 7a915fb commit e778ad9

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
@@ -862,6 +862,36 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
862862
return results
863863

864864

865+
def run_pr_author_checks(
866+
flags: list[str], pr_commits: list[Commit]
867+
) -> list[ScopeResult]:
868+
"""Run the author checks on every author of the pull request's commits.
869+
870+
Checking only the branch tip let an earlier commit by another author,
871+
say a GitHub noreply address that ``author_email_pattern`` forbids, go
872+
through. Each distinct author is checked once, on the newest commit
873+
that carries it, so a one-person pull request still reports one scope
874+
per check. A commit the clone lacks is fetched without its history.
875+
876+
Returns ``[]`` when no author can be read, or a commit cannot be had
877+
even by fetching it, so the caller falls back to the branch tip as before.
878+
"""
879+
newest: dict[tuple[str, str], str] = {}
880+
for sha, _msg in pr_commits:
881+
author = API_AUTHORS.get(sha) or (_local_author(sha) if sha else None)
882+
if author:
883+
newest.pop(author, None)
884+
newest[author] = sha
885+
revs = [sha for sha in newest.values() if _rev_resolves(sha) or _fetch_commit(sha)]
886+
if len(revs) != len(newest):
887+
return []
888+
return [
889+
check_scope(CHECK_LABELS[flag], [flag, "--rev", sha], sha=sha)
890+
for flag in flags
891+
for sha in revs
892+
]
893+
894+
865895
def check_empty_message(
866896
label: str, sha: str, env: dict[str, str] | None
867897
) -> ScopeResult:
@@ -919,7 +949,8 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
919949
scope is reported)
920950
3. Individual PR commit messages (when ``message: true`` and in a PR event;
921951
AI rules only with ``message-ai-attribution: true``)
922-
4. All remaining checks (branch, author name/email, etc.)
952+
4. Author checks on every PR commit's author, then the remaining checks
953+
(branch; outside a PR, the HEAD commit and its author)
923954
924955
Outside of a PR event all enabled checks are handed to the CLI at once.
925956
"""
@@ -946,6 +977,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
946977
PR_BODY_AI_TOOLS[:] = scope.ai_tools
947978

948979
# ---- 3. Commit message checks -----------------------------------------
980+
pr_commits: list[Commit] | None = None
949981
if MESSAGE_ENABLED or MESSAGE_AI_ATTRIBUTION_ENABLED:
950982
pr_commits = get_pr_commit_messages()
951983
if pr_commits:
@@ -969,6 +1001,14 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
9691001
)
9701002
args = [a for a in args if a != "--message"]
9711003
rev = None
1004+
author_flags = [flag for flag in args if flag in AUTHOR_FLAGS]
1005+
if is_pr_event() and author_flags:
1006+
if pr_commits is None:
1007+
pr_commits = get_pr_commit_messages()
1008+
author_scopes = run_pr_author_checks(author_flags, pr_commits)
1009+
if author_scopes:
1010+
results.extend(author_scopes)
1011+
args = [a for a in args if a not in AUTHOR_FLAGS]
9721012
if is_pr_event() and any(flag in AUTHOR_FLAGS for flag in args):
9731013
rev = pr_head_rev()
9741014
if rev is None:

‎main_test.py‎

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

41974197

4198+
class TestPrAuthorChecksEveryCommit(unittest.TestCase):
4199+
"""The author checks read every PR commit's author, not only the tip."""
4200+
4201+
def setUp(self):
4202+
main.API_AUTHORS.clear()
4203+
self.addCleanup(main.API_AUTHORS.clear)
4204+
4205+
def _run(self, commits, authors, resolves=True, message=False):
4206+
commands: list[list[str]] = []
4207+
4208+
def run(command, **_kwargs):
4209+
commands.append(command)
4210+
if command[:2] == ["git", "rev-parse"]:
4211+
return MagicMock(returncode=0 if resolves else 1, stdout="")
4212+
if command[:2] == ["git", "fetch"]:
4213+
return MagicMock(returncode=1, stdout="")
4214+
if "--message" in command:
4215+
return MagicMock(
4216+
returncode=0, stdout=json_output(make_check("message"))
4217+
)
4218+
check = command[3].lstrip("-").replace("-", "_")
4219+
sha = command[-1]
4220+
status = "fail" if "noreply" in authors[sha][1] else "pass"
4221+
return MagicMock(
4222+
returncode=0,
4223+
stdout=json_output(make_check(check, status=status, value=sha)),
4224+
)
4225+
4226+
with (
4227+
patch("main.MESSAGE_ENABLED", message),
4228+
patch("main.BRANCH_ENABLED", False),
4229+
patch("main.AUTHOR_NAME_ENABLED", False),
4230+
patch("main.AUTHOR_EMAIL_ENABLED", True),
4231+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}),
4232+
patch("main.get_pr_commit_messages", return_value=commits) as listed,
4233+
patch("main._local_author", side_effect=lambda sha: authors.get(sha)),
4234+
patch("main.get_pr_head_sha", return_value=commits[-1][0]),
4235+
patch("main.subprocess.run", side_effect=run),
4236+
patch("sys.stdout", io.StringIO()),
4237+
):
4238+
rc, results = main.run_commit_check()
4239+
self.listed_calls = listed.call_count
4240+
return rc, results, commands
4241+
4242+
def test_the_commits_are_listed_once_for_messages_and_authors(self):
4243+
commits = [("a1", "feat: one")]
4244+
_rc, results, _commands = self._run(
4245+
commits, {"a1": ("Dev", "dev@example.org")}, message=True
4246+
)
4247+
self.assertEqual(self.listed_calls, 1)
4248+
self.assertEqual([s.label for s in results], ["Commit 1/1", "Author email"])
4249+
4250+
def test_a_commit_whose_author_cannot_be_read_is_passed_over(self):
4251+
commits = [("a1", "feat: one"), ("b2", "feat: two")]
4252+
_rc, results, _commands = self._run(commits, {"b2": ("Dev", "dev@example.org")})
4253+
self.assertEqual([(s.label, s.sha) for s in results], [("Author email", "b2")])
4254+
4255+
def test_an_earlier_commit_by_another_author_is_checked(self):
4256+
"""FreeBSD atf forbids noreply emails; the tip alone hid this one."""
4257+
commits = [("a1", "feat: one"), ("b2", "feat: two")]
4258+
authors = {
4259+
"a1": ("Dev", "dev@users.noreply.github.com"),
4260+
"b2": ("Dev", "dev@example.org"),
4261+
}
4262+
rc, results, commands = self._run(commits, authors)
4263+
self.assertEqual(rc, 1)
4264+
self.assertEqual(
4265+
[(s.label, s.sha, s.status) for s in results],
4266+
[("Author email", "a1", "fail"), ("Author email", "b2", "pass")],
4267+
)
4268+
self.assertIn(
4269+
["commit-check", "--format", "json", "--author-email", "--rev", "a1"],
4270+
commands,
4271+
)
4272+
4273+
def test_one_author_is_checked_once_on_the_newest_commit(self):
4274+
commits = [("a1", "feat: one"), ("b2", "feat: two"), ("c3", "feat: three")]
4275+
same = ("Dev", "dev@example.org")
4276+
rc, results, _commands = self._run(
4277+
commits, dict.fromkeys(("a1", "b2", "c3"), same)
4278+
)
4279+
self.assertEqual(rc, 0)
4280+
self.assertEqual([(s.label, s.sha) for s in results], [("Author email", "c3")])
4281+
4282+
def test_an_api_listed_author_is_used(self):
4283+
main.API_AUTHORS["a1"] = ("Dev", "dev@users.noreply.github.com")
4284+
_rc, results, _ = self._run(
4285+
[("a1", "feat: one")], {"a1": ("Dev", "dev@users.noreply.github.com")}
4286+
)
4287+
self.assertEqual([(s.sha, s.status) for s in results], [("a1", "fail")])
4288+
4289+
def test_a_commit_that_cannot_be_had_falls_back_to_the_tip(self):
4290+
"""Neither in the clone nor fetchable: the tip path decides (here: skip)."""
4291+
commits = [("a1", "feat: one")]
4292+
_rc, results, commands = self._run(
4293+
commits, {"a1": ("Dev", "dev@example.org")}, resolves=False
4294+
)
4295+
self.assertEqual(
4296+
[(s.label, s.status) for s in results], [("Author email", "skip")]
4297+
)
4298+
self.assertFalse([c for c in commands if c[0] == "commit-check"], commands)
4299+
4300+
41984301
class TestBooleanInputs(unittest.TestCase):
41994302
"""A boolean input is true or false; anything else fails the step."""
42004303

0 commit comments

Comments
 (0)