diff --git a/.github/workflows/BUILD.bazel b/.github/workflows/BUILD.bazel new file mode 100644 index 0000000000..e9bb929441 --- /dev/null +++ b/.github/workflows/BUILD.bazel @@ -0,0 +1,4 @@ +exports_files( + glob(["*"]), + visibility = ["//tests:__subpackages__"], +) diff --git a/.github/workflows/on_comment.py b/.github/workflows/on_comment.py new file mode 100755 index 0000000000..4d4b4ebb6a --- /dev/null +++ b/.github/workflows/on_comment.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Parses issue and PR comments to dispatch release and backport workflows.""" + +import os +import re +import subprocess +import sys + + +def _get_bool(key: str, default: bool = False) -> bool: + """Returns boolean value for an environment variable.""" + val = os.environ.get(key) + if val is None: + return default + return val.lower() == "true" + + +def _match_command(command: str, comment_body: str) -> re.Match[str] | None: + """Matches a slash command at the start of any line, capturing optional trailing args.""" + cmd = command.lstrip("/") + return re.search( + rf"^\s*/{re.escape(cmd)}(?:\s+(\S.*?))?\s*$", + comment_body, + re.MULTILINE, + ) + + +def _write_github_output(key: str, value: str) -> None: + """Appends key=value to $GITHUB_OUTPUT.""" + path = os.environ["GITHUB_OUTPUT"] + with open(path, "a", encoding="utf-8") as f: + f.write(f"{key}={value}\n") + + +def _write_github_env(key: str, value: str) -> None: + """Appends key=value to $GITHUB_ENV.""" + path = os.environ["GITHUB_ENV"] + with open(path, "a", encoding="utf-8") as f: + f.write(f"{key}={value}\n") + + +def _add_comment_reaction(repo: str, comment_id: str, content: str) -> None: + """Adds a reaction to a GitHub comment using the gh CLI.""" + subprocess.run( + [ + "gh", + "api", + "--method", + "POST", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + f"/repos/{repo}/issues/comments/{comment_id}/reactions", + "-f", + f"content={content}", + ], + check=False, + ) + + +def _react_negative(repo: str, comment_id: str) -> None: + """Logs error and adds a negative reaction to the comment.""" + print("Error: No PRs specified for add-backports.", file=sys.stderr) + if comment_id and repo: + _add_comment_reaction(repo=repo, comment_id=comment_id, content="-1") + + +def _process_release_issue_comment( + comment_body: str, + issue_number: str, + repo: str = "", + comment_id: str = "", +) -> None: + """Processes comments on a release tracking issue.""" + if _match_command("create-rc", comment_body): + _write_github_output("command", "create-rc") + return + + if m := _match_command("prepare-complete", comment_body): + _write_github_output("command", "prepare-complete") + if pr_arg := re.sub(r"[\s#]", "", m.group(1)) if m.group(1) else "": + _write_github_output("pr_number", pr_arg) + return + + if _match_command("create-release-branch", comment_body): + _write_github_output("command", "create-release-branch") + return + + if _match_command("prepare", comment_body): + _write_github_output("command", "prepare") + return + + if _match_command("process-backports", comment_body): + _write_github_output("command", "process-backports") + return + + if m := _match_command("add-backports", comment_body): + raw_args = m.group(1) if m.group(1) else "" + items = [item for item in re.split(r"[\s,]+", raw_args) if item] + if csv := ",".join(items): + _write_github_output("command", "add-backports") + _write_github_output("backports", csv) + else: + _write_github_output("command", "none") + _react_negative(repo=repo, comment_id=comment_id) + return + + if _match_command("promote", comment_body): + _write_github_output("command", "promote") + return + + _write_github_output("command", "none") + + +def _process_backport_issue_comment(comment_body: str) -> None: + """Processes comments on a backport tracking issue.""" + if _match_command("prepare", comment_body): + _write_github_output("command", "backport-prepare") + return + + if _match_command("create-releases", comment_body): + _write_github_output("command", "backport-create-releases") + return + + _write_github_output("command", "none") + + +def _process_pr_comment(comment_body: str, pr_number: str) -> None: + """Processes comments on a pull request.""" + if _match_command("backport", comment_body): + _write_github_output("command", "pr-backport") + _write_github_output("pr_number", pr_number) + return + + if _match_command("prepare-complete", comment_body): + _write_github_output("command", "prepare-complete") + _write_github_output("pr_number", pr_number) + return + + _write_github_output("command", "none") + + +def process_comment() -> int: + """Processes a comment from environment variables and dispatches actions.""" + comment_body = os.environ.get("COMMENT_BODY", "") + is_pr = _get_bool("IS_PR") + event_number = os.environ.get("EVENT_NUMBER", "") + has_release_label = _get_bool("HAS_RELEASE_LABEL") + has_backport_label = _get_bool("HAS_BACKPORT_LABEL") + comment_id = os.environ.get("COMMENT_ID", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + + if is_pr: + _process_pr_comment( + comment_body=comment_body, + pr_number=event_number, + ) + return 0 + + issue_number = event_number + _write_github_output("issue_number", issue_number) + _write_github_env("issue_number", issue_number) + + if has_release_label: + _process_release_issue_comment( + comment_body=comment_body, + issue_number=issue_number, + repo=repo, + comment_id=comment_id, + ) + elif has_backport_label: + _process_backport_issue_comment( + comment_body=comment_body, + ) + else: + _write_github_output("command", "none") + + return 0 + + +def _main() -> None: + sys.exit(process_comment()) + + +if __name__ == "__main__": + _main() diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index a4330a0d6f..e94c382f26 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -29,6 +29,10 @@ jobs: pr_number: ${{ steps.parse.outputs.pr_number }} backports: ${{ steps.parse.outputs.backports }} steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" - name: Parse comment id: parse env: @@ -37,88 +41,9 @@ jobs: EVENT_NUMBER: "${{ github.event.issue.number }}" HAS_RELEASE_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: release') }}" HAS_BACKPORT_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: backport-pr') }}" + COMMENT_ID: "${{ github.event.comment.id }}" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ "$IS_PR" = "false" ]; then - # Set issue number for non-PR issues (release or backport tracking issues) - issue_number=$EVENT_NUMBER - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_ENV" - - # Check if it's a release tracking issue - if [ "$HAS_RELEASE_LABEL" = "true" ]; then - # Handle /create-rc comment - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then - echo "command=create-rc" >> "$GITHUB_OUTPUT" - # Handle /prepare-complete comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare-complete([[:space:]]|$)'; then - echo "command=prepare-complete" >> "$GITHUB_OUTPUT" - pr_arg=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/prepare-complete([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/prepare-complete[[:space:]]*//' | tr -d '[:space:]#') - if [ -n "$pr_arg" ]; then - echo "pr_number=$pr_arg" >> "$GITHUB_OUTPUT" - fi - # Handle /create-release-branch comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-release-branch([[:space:]]|$)'; then - echo "command=create-release-branch" >> "$GITHUB_OUTPUT" - # Handle /prepare comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=prepare" >> "$GITHUB_OUTPUT" - # Handle /process-backports comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then - echo "command=process-backports" >> "$GITHUB_OUTPUT" - # Handle /add-backports comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then - args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') - args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') - csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') - if [ -n "$csv" ]; then - echo "command=add-backports" >> "$GITHUB_OUTPUT" - echo "backports=$csv" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - echo "Error: No PRs specified for add-backports." >&2 - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ - -f "content=-1" - fi - # Handle /promote comment - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then - echo "command=promote" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - # Check if it's a backport tracking issue - elif [ "$HAS_BACKPORT_LABEL" = "true" ]; then - # Handle /prepare comment for backports - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=backport-prepare" >> "$GITHUB_OUTPUT" - # Handle /create-releases comment for backports - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-releases([[:space:]]|$)'; then - echo "command=backport-create-releases" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - elif [ "$IS_PR" = "true" ]; then - pr_number=$EVENT_NUMBER - # Handle /backport comment on PR - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then - echo "command=pr-backport" >> "$GITHUB_OUTPUT" - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare-complete([[:space:]]|$)'; then - echo "command=prepare-complete" >> "$GITHUB_OUTPUT" - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi + run: .github/workflows/on_comment.py call_create_rc: needs: parse_comment diff --git a/tests/workflows/BUILD.bazel b/tests/workflows/BUILD.bazel new file mode 100644 index 0000000000..d5809a6d1c --- /dev/null +++ b/tests/workflows/BUILD.bazel @@ -0,0 +1,21 @@ +load("//python:py_library.bzl", "py_library") +load("//tests/support:support.bzl", "NOT_WINDOWS") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") + +py_library( + name = "on_comment", + testonly = True, + srcs = ["//.github/workflows:on_comment.py"], + imports = ["../../.github/workflows"], + target_compatible_with = NOT_WINDOWS, +) + +pytest_test( + name = "on_comment_test", + srcs = ["on_comment_test.py"], + target_compatible_with = NOT_WINDOWS, + deps = [ + ":on_comment", + "@pypi//pytest_mock", + ], +) diff --git a/tests/workflows/on_comment_test.py b/tests/workflows/on_comment_test.py new file mode 100644 index 0000000000..9ec9413db1 --- /dev/null +++ b/tests/workflows/on_comment_test.py @@ -0,0 +1,353 @@ +"""Tests for .github/workflows/on_comment.py.""" + +import dataclasses +from pathlib import Path + +import pytest +from on_comment import ( + _main, + process_comment, +) + + +@dataclasses.dataclass +class GitHubActionEnv: + output_file: Path + env_file: Path + + def read_outputs(self) -> dict[str, str]: + if not self.output_file.exists(): + return {} + res = {} + for line in self.output_file.read_text().splitlines(): + if "=" in line: + k, v = line.split("=", 1) + res[k] = v + return res + + def read_env(self) -> dict[str, str]: + if not self.env_file.exists(): + return {} + res = {} + for line in self.env_file.read_text().splitlines(): + if "=" in line: + k, v = line.split("=", 1) + res[k] = v + return res + + +@pytest.fixture(name="gha_env", autouse=True) +def fixture_gha_env(tmp_path, monkeypatch) -> GitHubActionEnv: + """Fixture that always sets GITHUB_OUTPUT and GITHUB_ENV environment variables.""" + out_file = tmp_path / "github_output.txt" + env_file = tmp_path / "github_env.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + monkeypatch.setenv("GITHUB_ENV", str(env_file)) + return GitHubActionEnv(output_file=out_file, env_file=env_file) + + +@pytest.fixture(name="mock_add_reaction", autouse=True) +def fixture_mock_add_reaction(mocker): + """Fixture that mocks out _add_comment_reaction by default for all tests.""" + return mocker.patch("on_comment._add_comment_reaction") + + +def _run_comment( + monkeypatch, + comment_body: str, + *, + is_pr: str = "false", + event_number: str = "100", + has_release_label: str = "false", + has_backport_label: str = "false", + comment_id: str = "999", + repo: str = "test/repo", +) -> None: + monkeypatch.setenv("COMMENT_BODY", comment_body) + monkeypatch.setenv("IS_PR", is_pr) + monkeypatch.setenv("EVENT_NUMBER", event_number) + monkeypatch.setenv("HAS_RELEASE_LABEL", has_release_label) + monkeypatch.setenv("HAS_BACKPORT_LABEL", has_backport_label) + monkeypatch.setenv("COMMENT_ID", comment_id) + monkeypatch.setenv("GITHUB_REPOSITORY", repo) + process_comment() + + +def test_release_issue_create_rc(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/create-rc", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "create-rc", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_prepare_complete_with_arg(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare-complete #200", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "prepare-complete", + "pr_number": "200", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_prepare_complete_no_arg(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare-complete", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "prepare-complete", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_create_release_branch(monkeypatch, gha_env): + _run_comment( + monkeypatch, + " /create-release-branch ", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "create-release-branch", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_prepare(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "prepare", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_process_backports(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/process-backports", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "process-backports", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_add_backports(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/add-backports 1, 2, 3", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "add-backports", + "backports": "1,2,3", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_add_backports_hashes(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/add-backports #123 #567", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "add-backports", + "backports": "#123,#567", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_add_backports_empty( + monkeypatch, gha_env, mock_add_reaction, capsys +): + _run_comment( + monkeypatch, + "/add-backports", + has_release_label="true", + repo="bazel-contrib/rules_python", + comment_id="789", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "none", + } + assert gha_env.read_env() == {"issue_number": "100"} + captured = capsys.readouterr() + assert "Error: No PRs specified for add-backports." in captured.err + mock_add_reaction.assert_called_once_with( + repo="bazel-contrib/rules_python", + comment_id="789", + content="-1", + ) + + +def test_release_issue_promote(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/promote", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "promote", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_unknown_comment(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "Just some ordinary comment", + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "none", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_release_issue_multiline_comment(monkeypatch, gha_env): + body = "LGTM!\n/prepare\nWill test later." + _run_comment( + monkeypatch, + body, + has_release_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "prepare", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_backport_issue_prepare(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare", + has_backport_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "backport-prepare", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_backport_issue_create_releases(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/create-releases", + has_backport_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "backport-create-releases", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_backport_issue_unknown_comment(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "Random text", + has_backport_label="true", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "none", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_unlabeled_issue_ignored(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare", + has_release_label="false", + has_backport_label="false", + ) + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "none", + } + assert gha_env.read_env() == {"issue_number": "100"} + + +def test_pr_backport(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/backport", + is_pr="true", + event_number="300", + ) + assert gha_env.read_outputs() == { + "command": "pr-backport", + "pr_number": "300", + } + assert gha_env.read_env() == {} + + +def test_pr_prepare_complete(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "/prepare-complete", + is_pr="true", + event_number="300", + ) + assert gha_env.read_outputs() == { + "command": "prepare-complete", + "pr_number": "300", + } + assert gha_env.read_env() == {} + + +def test_pr_unknown_comment(monkeypatch, gha_env): + _run_comment( + monkeypatch, + "Looks good!", + is_pr="true", + event_number="300", + ) + assert gha_env.read_outputs() == {"command": "none"} + assert gha_env.read_env() == {} + + +def test_main_cli_execution(monkeypatch, gha_env): + monkeypatch.setenv("COMMENT_BODY", "/create-rc") + monkeypatch.setenv("IS_PR", "false") + monkeypatch.setenv("EVENT_NUMBER", "42") + monkeypatch.setenv("HAS_RELEASE_LABEL", "true") + monkeypatch.setenv("HAS_BACKPORT_LABEL", "false") + + with pytest.raises(SystemExit): + _main() + + assert gha_env.read_outputs() == { + "issue_number": "42", + "command": "create-rc", + } + assert gha_env.read_env() == {"issue_number": "42"}