Skip to content

Commit 9b07f3f

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/check-every-pr-commit-author
# Conflicts: # main.py # main_test.py
2 parents 15008b6 + 63e0608 commit 9b07f3f

4 files changed

Lines changed: 206 additions & 21 deletions

File tree

‎.github/workflows/commit-check.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ jobs:
2424
fetch-depth: 0 # Required for merge-base checks
2525
# Nothing after checkout needs authenticated git.
2626
persist-credentials: false
27+
# The install step must never run a pip.py or venv.py from the
28+
# checkout in place of pip or venv. If it did, these stop the job.
29+
- name: Plant pip.py and venv.py in the workspace
30+
shell: bash
31+
run: |
32+
for name in pip venv; do
33+
echo 'raise SystemExit("a workspace module ran in place of pip or venv")' > "$name.py"
34+
done
2735
- uses: ./ # self test
2836
with:
2937
message: true

‎action.yml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ runs:
7373
7474
# The pinned commit-check needs Python 3.11+. Say so here: on an older
7575
# python3, pip would only report that no matching version exists.
76-
if ! $PYTHON_CMD -c 'import sys; sys.exit(sys.version_info < (3, 11))'; then
76+
if ! $PYTHON_CMD -I -c 'import sys; sys.exit(sys.version_info < (3, 11))'; then
7777
echo "::error::commit-check-action needs Python 3.11 or newer on PATH, found $($PYTHON_CMD --version 2>&1). Add actions/setup-python with python-version 3.11 or newer before this step."
7878
exit 1
7979
fi
@@ -84,7 +84,11 @@ runs:
8484
WORK="$RUNNER_TEMP/commit-check-action"
8585
rm -rf "$WORK" # a second invocation in the same job starts clean, so the wheel glob below matches exactly one file
8686
mkdir -p "$WORK/wheels"
87-
$PYTHON_CMD -m venv "$WORK/venv"
87+
# -I (isolated mode) everywhere below: `python -m` otherwise puts the
88+
# current directory, the caller's checkout, first on sys.path, so a
89+
# pip.py or venv.py at the repository root would run in place of pip
90+
# or venv, with this step's GITHUB_TOKEN.
91+
$PYTHON_CMD -I -m venv "$WORK/venv"
8892
if [[ "$RUNNER_OS" == "Windows" ]]; then
8993
source "$WORK/venv/Scripts/activate"
9094
else
@@ -94,7 +98,7 @@ runs:
9498
# One download of the pinned closure, into the scratch dir. Wheels only:
9599
# the offline install below cannot build an sdist, so a platform without
96100
# binary wheels fails here, with pip's message, rather than later.
97-
$PYTHON_CMD -m pip download -q --only-binary=:all: -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
101+
$PYTHON_CMD -I -m pip download -q --only-binary=:all: -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
98102
99103
# Verify the commit-check wheel's build provenance (PyGithub and the
100104
# transitive wheels are pinned but not attested; see README "Runner requirements").
@@ -106,7 +110,7 @@ runs:
106110
107111
# Install offline from the wheels we already downloaded and verified:
108112
# no second trip to PyPI, and the installed set is exactly the downloaded set.
109-
$PYTHON_CMD -m pip install -q --no-cache-dir --no-index --find-links "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
113+
$PYTHON_CMD -I -m pip install -q --no-cache-dir --no-index --find-links "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
110114
111115
$PYTHON_CMD "$GITHUB_ACTION_PATH/main.py"
112116
env:

‎main.py‎

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import re
1818
import subprocess
1919
import sys
20+
import tempfile
2021
import uuid
2122
from dataclasses import dataclass, field
2223
from typing import Any
@@ -66,9 +67,22 @@
6667
}
6768

6869

70+
#: Boolean inputs whose value was neither true nor false, as ``(name, value)``.
71+
#: ``main`` fails the step on them before any check runs.
72+
INVALID_FLAGS: list[tuple[str, str]] = []
73+
74+
6975
def env_flag(name: str, default: str = "false") -> bool:
70-
"""Read a GitHub Action boolean-style environment variable."""
71-
return os.getenv(name, default).lower() == "true"
76+
"""Read a GitHub Action boolean input: ``true`` or ``false``, any case.
77+
78+
Anything else (``yes``, ``on``, a typo) used to read as false and turn
79+
the check off without a word. It is now recorded in ``INVALID_FLAGS``,
80+
and the step fails with the input's name.
81+
"""
82+
value = os.getenv(name, default).strip().lower()
83+
if value not in ("true", "false"):
84+
INVALID_FLAGS.append((name, os.getenv(name, default)))
85+
return value == "true"
7286

7387

7488
def _reconfigure_io() -> None:
@@ -554,14 +568,13 @@ def parse_commit_messages(output: str) -> list[Commit]:
554568
555569
The fields alternate hash, message, hash, message; git puts a newline
556570
between commits, which lands on the front of the next hash and is
557-
stripped along with the message's trailing newline. Commits are paired
558-
before empty messages are dropped, so a blank message never shifts the
559-
hashes of the commits after it.
571+
stripped along with the message's trailing newline. A commit with an
572+
empty message is kept, as ``(sha, "")``: it is a commit of the pull
573+
request like any other, and ``allow_empty_commits`` is the rule that
574+
judges it.
560575
"""
561576
fields = [f.strip("\n") for f in output.split(COMMIT_MESSAGE_DELIMITER)]
562-
return [
563-
(sha, message) for sha, message in zip(fields[0::2], fields[1::2]) if message
564-
]
577+
return [(sha, message) for sha, message in zip(fields[0::2], fields[1::2]) if sha]
565578

566579

567580
def _messages_in_range(revision_range: str) -> list[Commit]:
@@ -818,9 +831,11 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
818831
if author:
819832
names = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL")
820833
env = {key: value for key, value in zip(names, author) if value} or None
821-
results.append(
822-
check_scope(label, ["--message"], input_text=msg, sha=sha, env=env)
823-
)
834+
if msg.strip():
835+
scope = check_scope(label, ["--message"], input_text=msg, sha=sha, env=env)
836+
else:
837+
scope = check_empty_message(label, sha, env)
838+
results.append(scope)
824839
return results
825840

826841

@@ -854,6 +869,23 @@ def run_pr_author_checks(
854869
]
855870

856871

872+
def check_empty_message(
873+
label: str, sha: str, env: dict[str, str] | None
874+
) -> ScopeResult:
875+
"""Check a commit whose message is empty, from an empty file.
876+
877+
Piped, an empty message reads to the CLI as no input at all, and it
878+
checks HEAD in its place: the merge commit, which passes. A file names
879+
the message, so the empty one is judged (``allow_empty_commits``).
880+
"""
881+
fd, path = tempfile.mkstemp(prefix="commit-msg-", dir=os.getenv("RUNNER_TEMP"))
882+
os.close(fd)
883+
try:
884+
return check_scope(label, ["--message", path], sha=sha, env=env)
885+
finally:
886+
os.unlink(path)
887+
888+
857889
def run_other_checks(args: list[str], rev: str | None = None) -> list[ScopeResult]:
858890
"""Run each non-message check (branch, author) once, as its own scope.
859891
@@ -1849,14 +1881,15 @@ def _is_bot(comment: Any) -> bool:
18491881
def _find_own_comments(comments: list[Any]) -> tuple[Any | None, list[Any]]:
18501882
"""Pick the comment to update and the ones to delete.
18511883
1852-
Returns ``(target, stale)``. Only comments carrying ``COMMENT_MARKER`` are
1853-
ever deleted — those are unambiguously ours. A comment from an earlier
1884+
Returns ``(target, stale)``. Only comments a bot posted with
1885+
``COMMENT_MARKER`` in them are ever edited or deleted: a person can paste
1886+
the marker too (quoting a report, say), and their comment is theirs. A comment from an earlier
18541887
version has no marker, so it is adopted (edited, which adds the marker)
18551888
when there is no marked comment yet, and only if a bot posted it: the
18561889
legacy signal is a title prefix, which a person can type by accident, and
18571890
editing someone's comment out from under them is not recoverable.
18581891
"""
1859-
marked = [c for c in comments if COMMENT_MARKER in c.body]
1892+
marked = [c for c in comments if COMMENT_MARKER in c.body and _is_bot(c)]
18601893
if marked:
18611894
return marked[-1], marked[:-1]
18621895

@@ -1927,7 +1960,8 @@ def add_pr_comments(results: list[ScopeResult]) -> int:
19271960
if not repo_name:
19281961
raise ValueError("GITHUB_REPOSITORY is not set")
19291962

1930-
g = Github(auth=Auth.Token(token))
1963+
api_url = os.getenv("GITHUB_API_URL", "https://api.github.com")
1964+
g = Github(auth=Auth.Token(token), base_url=api_url)
19311965
repo = g.get_repo(repo_name)
19321966
pull_request = repo.get_issue(pr_number)
19331967

@@ -2073,9 +2107,23 @@ def log_error_and_exit(ret_code: int) -> None:
20732107
sys.exit(ret_code)
20742108

20752109

2110+
def fail_on_invalid_flags() -> None:
2111+
"""Stop with an error when a boolean input is neither true nor false."""
2112+
if not INVALID_FLAGS:
2113+
return
2114+
for name, value in INVALID_FLAGS:
2115+
input_name = name.lower().replace("_", "-")
2116+
print(
2117+
f"::error::Input {input_name} must be true or false, got "
2118+
f"{_log_safe(repr(value))}"
2119+
)
2120+
sys.exit(1)
2121+
2122+
20762123
def main():
20772124
"""Main function to run commit-check and render all output surfaces."""
20782125
_reconfigure_io()
2126+
fail_on_invalid_flags()
20792127
log_env_vars()
20802128

20812129
ret_code, results = run_commit_check()

‎main_test.py‎

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,12 @@ def test_the_format_asks_git_for_the_hash_and_the_body(self):
245245
self.assertEqual(main.COMMIT_LOG_FORMAT, "--pretty=format:%H%x00%B%x00")
246246
self.assertNotIn("\x00", main.COMMIT_LOG_FORMAT)
247247

248-
def test_an_empty_message_does_not_shift_the_hashes_after_it(self):
248+
def test_an_empty_message_is_kept_and_does_not_shift_the_hashes(self):
249249
output = f"{SHA_A}\x00\n\x00\n{SHA_B}\x00feat: second\n\x00"
250-
self.assertEqual(main.parse_commit_messages(output), [(SHA_B, "feat: second")])
250+
self.assertEqual(
251+
main.parse_commit_messages(output),
252+
[(SHA_A, ""), (SHA_B, "feat: second")],
253+
)
251254

252255
def test_empty_output_is_no_commits(self):
253256
self.assertEqual(main.parse_commit_messages(""), [])
@@ -2455,6 +2458,7 @@ def test_updates_existing_comment_when_changed(self):
24552458
def test_skips_when_comment_is_up_to_date(self):
24562459
body = main.render_pr_comment([fail_scope()])
24572460
existing = MagicMock(body=body)
2461+
existing.user.type = "Bot"
24582462
mock_pull_request = MagicMock()
24592463
mock_pull_request.get_comments.return_value = [existing]
24602464
mock_repo = MagicMock()
@@ -2904,6 +2908,14 @@ def test_bot_comment_from_an_older_version_is_adopted(self):
29042908
self.assertIs(target, legacy)
29052909
self.assertEqual(stale, [])
29062910

2911+
def test_human_comment_with_the_marker_is_never_touched(self):
2912+
"""A person can paste the marker too, e.g. quoting a report."""
2913+
quoted = self._comment(f"{main.COMMENT_MARKER}\nquoted", user_type="User")
2914+
mine = self._comment(f"{main.COMMENT_MARKER}\nreport")
2915+
self.assertEqual(main._find_own_comments([quoted, mine]), (mine, []))
2916+
self.assertEqual(main._find_own_comments([mine, quoted]), (mine, []))
2917+
self.assertEqual(main._find_own_comments([quoted]), (None, []))
2918+
29072919
def test_no_comments_yields_nothing_to_update(self):
29082920
target, stale = main._find_own_comments([])
29092921
self.assertIsNone(target)
@@ -4199,3 +4211,116 @@ def test_a_commit_that_cannot_be_had_falls_back_to_the_tip(self):
41994211
[(s.label, s.status) for s in results], [("Author email", "skip")]
42004212
)
42014213
self.assertFalse([c for c in commands if c[0] == "commit-check"], commands)
4214+
4215+
4216+
class TestBooleanInputs(unittest.TestCase):
4217+
"""A boolean input is true or false; anything else fails the step."""
4218+
4219+
def setUp(self):
4220+
main.INVALID_FLAGS.clear()
4221+
self.addCleanup(main.INVALID_FLAGS.clear)
4222+
4223+
def test_true_and_false_in_any_case_and_padding(self):
4224+
for value, expected in (
4225+
("true", True),
4226+
(" True ", True),
4227+
("FALSE", False),
4228+
("false", False),
4229+
):
4230+
with patch.dict(os.environ, {"MESSAGE": value}):
4231+
self.assertIs(main.env_flag("MESSAGE"), expected, value)
4232+
self.assertEqual(main.INVALID_FLAGS, [])
4233+
4234+
def test_unset_input_takes_the_default(self):
4235+
with patch.dict(os.environ, {}, clear=True):
4236+
self.assertFalse(main.env_flag("MESSAGE"))
4237+
self.assertEqual(main.INVALID_FLAGS, [])
4238+
4239+
def test_other_values_are_recorded(self):
4240+
for value in ("yes", "on", "1", "maybe"):
4241+
with patch.dict(os.environ, {"MESSAGE": value}):
4242+
self.assertFalse(main.env_flag("MESSAGE"))
4243+
self.assertEqual(
4244+
main.INVALID_FLAGS,
4245+
[
4246+
("MESSAGE", "yes"),
4247+
("MESSAGE", "on"),
4248+
("MESSAGE", "1"),
4249+
("MESSAGE", "maybe"),
4250+
],
4251+
)
4252+
4253+
def test_invalid_input_fails_before_any_check(self):
4254+
main.INVALID_FLAGS.append(("PR_TITLE", "yes"))
4255+
out = io.StringIO()
4256+
with (
4257+
patch("sys.stdout", out),
4258+
patch("main.run_commit_check") as run,
4259+
self.assertRaises(SystemExit) as cm,
4260+
):
4261+
main.main()
4262+
self.assertEqual(cm.exception.code, 1)
4263+
run.assert_not_called()
4264+
self.assertIn(
4265+
"::error::Input pr-title must be true or false, got 'yes'", out.getvalue()
4266+
)
4267+
4268+
4269+
class TestPrCommentApiUrl(unittest.TestCase):
4270+
"""The PR comment goes to the same API host as every other call (GHES)."""
4271+
4272+
def test_comment_client_uses_github_api_url(self):
4273+
github_module = MagicMock()
4274+
pull_request = MagicMock()
4275+
pull_request.get_comments.return_value = []
4276+
github_module.Github.return_value.get_repo.return_value.get_issue.return_value = pull_request
4277+
with (
4278+
patch("main.PR_COMMENTS_ENABLED", True),
4279+
patch("main.is_fork_pr_with_readonly_token", return_value=False),
4280+
patch("main.get_pr_number", return_value=7),
4281+
patch.dict(
4282+
os.environ,
4283+
{
4284+
"GITHUB_EVENT_NAME": "pull_request",
4285+
"GITHUB_TOKEN": "t",
4286+
"GITHUB_REPOSITORY": "o/r",
4287+
"GITHUB_API_URL": "https://ghe.example.com/api/v3",
4288+
},
4289+
),
4290+
patch.dict(sys.modules, {"github": github_module}),
4291+
):
4292+
main.add_pr_comments([fail_scope()])
4293+
_, kwargs = github_module.Github.call_args
4294+
self.assertEqual(kwargs["base_url"], "https://ghe.example.com/api/v3")
4295+
4296+
4297+
class TestEmptyCommitMessage(unittest.TestCase):
4298+
"""An empty message is checked as empty, never as HEAD's message."""
4299+
4300+
def test_an_empty_message_goes_to_the_cli_as_an_empty_file(self):
4301+
seen: list[tuple[list[str], str | None, str]] = []
4302+
4303+
def run(command, **kwargs):
4304+
path = command[-1]
4305+
with open(path, encoding="utf-8") as f:
4306+
seen.append((command, kwargs.get("input"), f.read()))
4307+
return MagicMock(
4308+
returncode=1,
4309+
stdout=json_output(make_check("allow_empty_commits", status="fail")),
4310+
stderr="",
4311+
)
4312+
4313+
with (
4314+
patch("main.subprocess.run", side_effect=run),
4315+
patch("main._local_author", return_value=None),
4316+
):
4317+
results = main.run_pr_message_checks([("a1", "")])
4318+
command, piped, content = seen[0]
4319+
self.assertEqual(command[:4], ["commit-check", "--format", "json", "--message"])
4320+
self.assertIsNone(piped)
4321+
self.assertEqual(content, "")
4322+
self.assertFalse(os.path.exists(command[-1]))
4323+
self.assertEqual(
4324+
[(s.label, s.sha, s.status) for s in results],
4325+
[("Commit 1/1", "a1", "fail")],
4326+
)

0 commit comments

Comments
 (0)