refactor(release): move on_comment workflow logic into Python script (#4045)
The inline bash parsing logic in the comment dispatch workflow was
becoming complex and difficult to maintain or verify without executing
live GitHub Actions runs.
Move the comment dispatch logic into a dedicated Python script running
under Python 3.14, add a Bazel py_library target, and establish full
unit test coverage using pytest.
diff --git a/.github/workflows/BUILD.bazel b/.github/workflows/BUILD.bazel
new file mode 100644
index 0000000..e9bb929
--- /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 0000000..4d4b4eb
--- /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 a4330a0..e94c382 100644
--- a/.github/workflows/on_comment.yaml
+++ b/.github/workflows/on_comment.yaml
@@ -29,6 +29,10 @@
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 @@
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 0000000..d5809a6
--- /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 0000000..9ec9413
--- /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"}