Skip to content

Commit 6973358

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/message-ai-attribution
# Conflicts: # main.py
2 parents 6830e0d + 63e0608 commit 6973358

4 files changed

Lines changed: 206 additions & 23 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
@@ -77,7 +77,7 @@ runs:
7777
7878
# The pinned commit-check needs Python 3.11+. Say so here: on an older
7979
# python3, pip would only report that no matching version exists.
80-
if ! $PYTHON_CMD -c 'import sys; sys.exit(sys.version_info < (3, 11))'; then
80+
if ! $PYTHON_CMD -I -c 'import sys; sys.exit(sys.version_info < (3, 11))'; then
8181
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."
8282
exit 1
8383
fi
@@ -88,7 +88,11 @@ runs:
8888
WORK="$RUNNER_TEMP/commit-check-action"
8989
rm -rf "$WORK" # a second invocation in the same job starts clean, so the wheel glob below matches exactly one file
9090
mkdir -p "$WORK/wheels"
91-
$PYTHON_CMD -m venv "$WORK/venv"
91+
# -I (isolated mode) everywhere below: `python -m` otherwise puts the
92+
# current directory, the caller's checkout, first on sys.path, so a
93+
# pip.py or venv.py at the repository root would run in place of pip
94+
# or venv, with this step's GITHUB_TOKEN.
95+
$PYTHON_CMD -I -m venv "$WORK/venv"
9296
if [[ "$RUNNER_OS" == "Windows" ]]; then
9397
source "$WORK/venv/Scripts/activate"
9498
else
@@ -98,7 +102,7 @@ runs:
98102
# One download of the pinned closure, into the scratch dir. Wheels only:
99103
# the offline install below cannot build an sdist, so a platform without
100104
# binary wheels fails here, with pip's message, rather than later.
101-
$PYTHON_CMD -m pip download -q --only-binary=:all: -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
105+
$PYTHON_CMD -I -m pip download -q --only-binary=:all: -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
102106
103107
# Verify the commit-check wheel's build provenance (PyGithub and the
104108
# transitive wheels are pinned but not attested; see README "Runner requirements").
@@ -110,7 +114,7 @@ runs:
110114
111115
# Install offline from the wheels we already downloaded and verified:
112116
# no second trip to PyPI, and the installed set is exactly the downloaded set.
113-
$PYTHON_CMD -m pip install -q --no-cache-dir --no-index --find-links "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
117+
$PYTHON_CMD -I -m pip install -q --no-cache-dir --no-index --find-links "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt"
114118
115119
$PYTHON_CMD "$GITHUB_ACTION_PATH/main.py"
116120
env:

‎main.py‎

Lines changed: 63 additions & 17 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:
@@ -577,14 +591,13 @@ def parse_commit_messages(output: str) -> list[Commit]:
577591
578592
The fields alternate hash, message, hash, message; git puts a newline
579593
between commits, which lands on the front of the next hash and is
580-
stripped along with the message's trailing newline. Commits are paired
581-
before empty messages are dropped, so a blank message never shifts the
582-
hashes of the commits after it.
594+
stripped along with the message's trailing newline. A commit with an
595+
empty message is kept, as ``(sha, "")``: it is a commit of the pull
596+
request like any other, and ``allow_empty_commits`` is the rule that
597+
judges it.
583598
"""
584599
fields = [f.strip("\n") for f in output.split(COMMIT_MESSAGE_DELIMITER)]
585-
return [
586-
(sha, message) for sha, message in zip(fields[0::2], fields[1::2]) if message
587-
]
600+
return [(sha, message) for sha, message in zip(fields[0::2], fields[1::2]) if sha]
588601

589602

590603
def _messages_in_range(revision_range: str) -> list[Commit]:
@@ -841,14 +854,31 @@ def run_pr_message_checks(pr_commits: list[Commit]) -> list[ScopeResult]:
841854
if author:
842855
names = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL")
843856
env = {key: value for key, value in zip(names, author) if value} or None
844-
results.append(
845-
keep_ai_rules(
846-
check_scope(label, ["--message"], input_text=msg, sha=sha, env=env)
847-
)
848-
)
857+
if msg.strip():
858+
scope = check_scope(label, ["--message"], input_text=msg, sha=sha, env=env)
859+
else:
860+
scope = check_empty_message(label, sha, env)
861+
results.append(keep_ai_rules(scope))
849862
return results
850863

851864

865+
def check_empty_message(
866+
label: str, sha: str, env: dict[str, str] | None
867+
) -> ScopeResult:
868+
"""Check a commit whose message is empty, from an empty file.
869+
870+
Piped, an empty message reads to the CLI as no input at all, and it
871+
checks HEAD in its place: the merge commit, which passes. A file names
872+
the message, so the empty one is judged (``allow_empty_commits``).
873+
"""
874+
fd, path = tempfile.mkstemp(prefix="commit-msg-", dir=os.getenv("RUNNER_TEMP"))
875+
os.close(fd)
876+
try:
877+
return check_scope(label, ["--message", path], sha=sha, env=env)
878+
finally:
879+
os.unlink(path)
880+
881+
852882
def run_other_checks(args: list[str], rev: str | None = None) -> list[ScopeResult]:
853883
"""Run each non-message check (branch, author) once, as its own scope.
854884
@@ -1837,14 +1867,15 @@ def _is_bot(comment: Any) -> bool:
18371867
def _find_own_comments(comments: list[Any]) -> tuple[Any | None, list[Any]]:
18381868
"""Pick the comment to update and the ones to delete.
18391869
1840-
Returns ``(target, stale)``. Only comments carrying ``COMMENT_MARKER`` are
1841-
ever deleted — those are unambiguously ours. A comment from an earlier
1870+
Returns ``(target, stale)``. Only comments a bot posted with
1871+
``COMMENT_MARKER`` in them are ever edited or deleted: a person can paste
1872+
the marker too (quoting a report, say), and their comment is theirs. A comment from an earlier
18421873
version has no marker, so it is adopted (edited, which adds the marker)
18431874
when there is no marked comment yet, and only if a bot posted it: the
18441875
legacy signal is a title prefix, which a person can type by accident, and
18451876
editing someone's comment out from under them is not recoverable.
18461877
"""
1847-
marked = [c for c in comments if COMMENT_MARKER in c.body]
1878+
marked = [c for c in comments if COMMENT_MARKER in c.body and _is_bot(c)]
18481879
if marked:
18491880
return marked[-1], marked[:-1]
18501881

@@ -1915,7 +1946,8 @@ def add_pr_comments(results: list[ScopeResult]) -> int:
19151946
if not repo_name:
19161947
raise ValueError("GITHUB_REPOSITORY is not set")
19171948

1918-
g = Github(auth=Auth.Token(token))
1949+
api_url = os.getenv("GITHUB_API_URL", "https://api.github.com")
1950+
g = Github(auth=Auth.Token(token), base_url=api_url)
19191951
repo = g.get_repo(repo_name)
19201952
pull_request = repo.get_issue(pr_number)
19211953

@@ -2061,9 +2093,23 @@ def log_error_and_exit(ret_code: int) -> None:
20612093
sys.exit(ret_code)
20622094

20632095

2096+
def fail_on_invalid_flags() -> None:
2097+
"""Stop with an error when a boolean input is neither true nor false."""
2098+
if not INVALID_FLAGS:
2099+
return
2100+
for name, value in INVALID_FLAGS:
2101+
input_name = name.lower().replace("_", "-")
2102+
print(
2103+
f"::error::Input {input_name} must be true or false, got "
2104+
f"{_log_safe(repr(value))}"
2105+
)
2106+
sys.exit(1)
2107+
2108+
20642109
def main():
20652110
"""Main function to run commit-check and render all output surfaces."""
20662111
_reconfigure_io()
2112+
fail_on_invalid_flags()
20672113
log_env_vars()
20682114

20692115
ret_code, results = run_commit_check()

‎main_test.py‎

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

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

253256
def test_empty_output_is_no_commits(self):
254257
self.assertEqual(main.parse_commit_messages(""), [])
@@ -2540,6 +2543,7 @@ def test_updates_existing_comment_when_changed(self):
25402543
def test_skips_when_comment_is_up_to_date(self):
25412544
body = main.render_pr_comment([fail_scope()])
25422545
existing = MagicMock(body=body)
2546+
existing.user.type = "Bot"
25432547
mock_pull_request = MagicMock()
25442548
mock_pull_request.get_comments.return_value = [existing]
25452549
mock_repo = MagicMock()
@@ -2989,6 +2993,14 @@ def test_bot_comment_from_an_older_version_is_adopted(self):
29892993
self.assertIs(target, legacy)
29902994
self.assertEqual(stale, [])
29912995

2996+
def test_human_comment_with_the_marker_is_never_touched(self):
2997+
"""A person can paste the marker too, e.g. quoting a report."""
2998+
quoted = self._comment(f"{main.COMMENT_MARKER}\nquoted", user_type="User")
2999+
mine = self._comment(f"{main.COMMENT_MARKER}\nreport")
3000+
self.assertEqual(main._find_own_comments([quoted, mine]), (mine, []))
3001+
self.assertEqual(main._find_own_comments([mine, quoted]), (mine, []))
3002+
self.assertEqual(main._find_own_comments([quoted]), (None, []))
3003+
29923004
def test_no_comments_yields_nothing_to_update(self):
29933005
target, stale = main._find_own_comments([])
29943006
self.assertIsNone(target)
@@ -4181,3 +4193,116 @@ def test_a_missing_pygithub_warns(self):
41814193

41824194
if __name__ == "__main__":
41834195
unittest.main()
4196+
4197+
4198+
class TestBooleanInputs(unittest.TestCase):
4199+
"""A boolean input is true or false; anything else fails the step."""
4200+
4201+
def setUp(self):
4202+
main.INVALID_FLAGS.clear()
4203+
self.addCleanup(main.INVALID_FLAGS.clear)
4204+
4205+
def test_true_and_false_in_any_case_and_padding(self):
4206+
for value, expected in (
4207+
("true", True),
4208+
(" True ", True),
4209+
("FALSE", False),
4210+
("false", False),
4211+
):
4212+
with patch.dict(os.environ, {"MESSAGE": value}):
4213+
self.assertIs(main.env_flag("MESSAGE"), expected, value)
4214+
self.assertEqual(main.INVALID_FLAGS, [])
4215+
4216+
def test_unset_input_takes_the_default(self):
4217+
with patch.dict(os.environ, {}, clear=True):
4218+
self.assertFalse(main.env_flag("MESSAGE"))
4219+
self.assertEqual(main.INVALID_FLAGS, [])
4220+
4221+
def test_other_values_are_recorded(self):
4222+
for value in ("yes", "on", "1", "maybe"):
4223+
with patch.dict(os.environ, {"MESSAGE": value}):
4224+
self.assertFalse(main.env_flag("MESSAGE"))
4225+
self.assertEqual(
4226+
main.INVALID_FLAGS,
4227+
[
4228+
("MESSAGE", "yes"),
4229+
("MESSAGE", "on"),
4230+
("MESSAGE", "1"),
4231+
("MESSAGE", "maybe"),
4232+
],
4233+
)
4234+
4235+
def test_invalid_input_fails_before_any_check(self):
4236+
main.INVALID_FLAGS.append(("PR_TITLE", "yes"))
4237+
out = io.StringIO()
4238+
with (
4239+
patch("sys.stdout", out),
4240+
patch("main.run_commit_check") as run,
4241+
self.assertRaises(SystemExit) as cm,
4242+
):
4243+
main.main()
4244+
self.assertEqual(cm.exception.code, 1)
4245+
run.assert_not_called()
4246+
self.assertIn(
4247+
"::error::Input pr-title must be true or false, got 'yes'", out.getvalue()
4248+
)
4249+
4250+
4251+
class TestPrCommentApiUrl(unittest.TestCase):
4252+
"""The PR comment goes to the same API host as every other call (GHES)."""
4253+
4254+
def test_comment_client_uses_github_api_url(self):
4255+
github_module = MagicMock()
4256+
pull_request = MagicMock()
4257+
pull_request.get_comments.return_value = []
4258+
github_module.Github.return_value.get_repo.return_value.get_issue.return_value = pull_request
4259+
with (
4260+
patch("main.PR_COMMENTS_ENABLED", True),
4261+
patch("main.is_fork_pr_with_readonly_token", return_value=False),
4262+
patch("main.get_pr_number", return_value=7),
4263+
patch.dict(
4264+
os.environ,
4265+
{
4266+
"GITHUB_EVENT_NAME": "pull_request",
4267+
"GITHUB_TOKEN": "t",
4268+
"GITHUB_REPOSITORY": "o/r",
4269+
"GITHUB_API_URL": "https://ghe.example.com/api/v3",
4270+
},
4271+
),
4272+
patch.dict(sys.modules, {"github": github_module}),
4273+
):
4274+
main.add_pr_comments([fail_scope()])
4275+
_, kwargs = github_module.Github.call_args
4276+
self.assertEqual(kwargs["base_url"], "https://ghe.example.com/api/v3")
4277+
4278+
4279+
class TestEmptyCommitMessage(unittest.TestCase):
4280+
"""An empty message is checked as empty, never as HEAD's message."""
4281+
4282+
def test_an_empty_message_goes_to_the_cli_as_an_empty_file(self):
4283+
seen: list[tuple[list[str], str | None, str]] = []
4284+
4285+
def run(command, **kwargs):
4286+
path = command[-1]
4287+
with open(path, encoding="utf-8") as f:
4288+
seen.append((command, kwargs.get("input"), f.read()))
4289+
return MagicMock(
4290+
returncode=1,
4291+
stdout=json_output(make_check("allow_empty_commits", status="fail")),
4292+
stderr="",
4293+
)
4294+
4295+
with (
4296+
patch("main.subprocess.run", side_effect=run),
4297+
patch("main._local_author", return_value=None),
4298+
):
4299+
results = main.run_pr_message_checks([("a1", "")])
4300+
command, piped, content = seen[0]
4301+
self.assertEqual(command[:4], ["commit-check", "--format", "json", "--message"])
4302+
self.assertIsNone(piped)
4303+
self.assertEqual(content, "")
4304+
self.assertFalse(os.path.exists(command[-1]))
4305+
self.assertEqual(
4306+
[(s.label, s.sha, s.status) for s in results],
4307+
[("Commit 1/1", "a1", "fail")],
4308+
)

0 commit comments

Comments
 (0)