Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
exports_files(
glob(["*"]),
visibility = ["//tests:__subpackages__"],
)
187 changes: 187 additions & 0 deletions .github/workflows/on_comment.py
Original file line number Diff line number Diff line change
@@ -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()
87 changes: 6 additions & 81 deletions .github/workflows/on_comment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/workflows/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
Loading