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
7 changes: 4 additions & 3 deletions .github/workflows/integration-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ jobs:
- name: Create consolidated PR comment
if: github.event_name == 'pull_request_target'
run: |
COMMENT_BODY=$(cat consolidated_report.md)
# Sanitize @OpenHands mentions to prevent self-mention loops
COMMENT_BODY=$(uv run python -c "from openhands.sdk.utils.github import sanitize_openhands_mentions; import sys; print(sanitize_openhands_mentions(sys.stdin.read()), end='')" < consolidated_report.md)
# Use GitHub CLI to create comment with explicit PR number
echo "$COMMENT_BODY" | gh pr comment ${{ github.event.pull_request.number }} --body-file -
env:
Expand All @@ -243,8 +244,8 @@ jobs:
if: github.event_name == 'schedule'
id: read_report
run: |
# Read the report and set as output
REPORT_CONTENT=$(cat consolidated_report.md)
# Read and sanitize the report, then set as output
REPORT_CONTENT=$(uv run python -c "from openhands.sdk.utils.github import sanitize_openhands_mentions; import sys; print(sanitize_openhands_mentions(sys.stdin.read()), end='')" < consolidated_report.md)
echo "report<<EOF" >> $GITHUB_OUTPUT
echo "$REPORT_CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/run-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ jobs:
API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/issues/${PR_NUMBER}/comments"
fi
# Function to sanitize @OpenHands mentions using the SDK utility
sanitize_comment() {
local text="$1"
printf "%s" "$text" | uv run python -c "from openhands.sdk.utils.github import sanitize_openhands_mentions; import sys; print(sanitize_openhands_mentions(sys.stdin.read()), end='')"
}
# Function to update PR comment
update_comment() {
# Skip if not a PR event
Expand All @@ -116,6 +122,9 @@ jobs:
local comment_body="$1"
local response
# Sanitize @OpenHands mentions before posting
comment_body=$(sanitize_comment "$comment_body")
if [ -z "$COMMENT_ID" ]; then
# Create new comment
response=$(curl -s -X POST \
Expand Down Expand Up @@ -303,7 +312,8 @@ jobs:
shell: bash
run: |
if [ -f examples_report.md ]; then
REPORT_CONTENT=$(cat examples_report.md)
# Sanitize @OpenHands mentions before posting
REPORT_CONTENT=$(uv run python -c "from openhands.sdk.utils.github import sanitize_openhands_mentions; import sys; print(sanitize_openhands_mentions(sys.stdin.read()), end='')" < examples_report.md)
echo "report<<EOF" >> $GITHUB_OUTPUT
echo "$REPORT_CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
Expand Down
4 changes: 4 additions & 0 deletions examples/03_github_workflows/02_pr_review/agent_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

from openhands.sdk import LLM, Conversation, get_logger # noqa: E402
from openhands.sdk.conversation import get_agent_final_response # noqa: E402
from openhands.sdk.utils.github import sanitize_openhands_mentions # noqa: E402
from openhands.tools.preset.default import get_default_agent # noqa: E402


Expand All @@ -51,6 +52,9 @@ def post_review_comment(review_content: str) -> None:
Args:
review_content: The review content to post
"""
# Sanitize @OpenHands mentions to prevent self-mention loops
review_content = sanitize_openhands_mentions(review_content)

logger.info("Posting review comment to GitHub...")
pr_number = os.getenv("PR_NUMBER")
repo_name = os.getenv("REPO_NAME")
Expand Down
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
deprecated,
warn_deprecated,
)
from .github import sanitize_openhands_mentions
from .truncate import (
DEFAULT_TEXT_CONTENT_LIMIT,
DEFAULT_TRUNCATE_NOTICE,
Expand All @@ -17,4 +18,5 @@
"maybe_truncate",
"deprecated",
"warn_deprecated",
"sanitize_openhands_mentions",
]
44 changes: 44 additions & 0 deletions openhands-sdk/openhands/sdk/utils/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Utility functions for GitHub integrations."""

import re


# Zero-width joiner character (U+200D)
# We use ZWJ instead of ZWSP (U+200B) because:
# - ZWJ is semantically more appropriate (joins characters without adding space)
# - ZWJ has better support in modern renderers
# - ZWJ is invisible and doesn't affect text rendering or selection
ZWJ = "\u200d"


def sanitize_openhands_mentions(text: str) -> str:
"""Sanitize @OpenHands mentions in text to prevent self-mention loops.

This function inserts a zero-width joiner (ZWJ) after the @ symbol in
@OpenHands mentions, making them non-clickable in GitHub comments while
preserving readability. The original case of the mention is preserved.

Args:
text: The text to sanitize

Returns:
Text with sanitized @OpenHands mentions (e.g., "@OpenHands" -> "@‍OpenHands")

Examples:
>>> sanitize_openhands_mentions("Thanks @OpenHands for the help!")
'Thanks @\\u200dOpenHands for the help!'
>>> sanitize_openhands_mentions("Check @openhands and @OPENHANDS")
'Check @\\u200dopenhands and @\\u200dOPENHANDS'
>>> sanitize_openhands_mentions("No mention here")
'No mention here'
"""
# Pattern to match @OpenHands mentions at word boundaries
# Uses re.IGNORECASE so we don't need [Oo]pen[Hh]ands
# Capture group preserves the original case
pattern = r"@(OpenHands)\b"

# Replace @ with @ + ZWJ while preserving the original case
# The \1 backreference preserves the matched case
sanitized = re.sub(pattern, f"@{ZWJ}\\1", text, flags=re.IGNORECASE)

return sanitized
129 changes: 129 additions & 0 deletions tests/sdk/utils/test_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Tests for GitHub utility functions."""

from openhands.sdk.utils.github import ZWJ, sanitize_openhands_mentions


def test_sanitize_basic_mention():
"""Test basic @OpenHands mention is sanitized."""
text = "Thanks @OpenHands for the help!"
expected = f"Thanks @{ZWJ}OpenHands for the help!"
assert sanitize_openhands_mentions(text) == expected


def test_sanitize_case_insensitive():
"""Test that mentions are sanitized regardless of case."""
test_cases = [
("Check @OpenHands here", f"Check @{ZWJ}OpenHands here"),
("Check @openhands here", f"Check @{ZWJ}openhands here"),
("Check @OPENHANDS here", f"Check @{ZWJ}OPENHANDS here"),
("Check @oPeNhAnDs here", f"Check @{ZWJ}oPeNhAnDs here"),
]
for input_text, expected in test_cases:
assert sanitize_openhands_mentions(input_text) == expected


def test_sanitize_multiple_mentions():
"""Test multiple mentions in the same text."""
text = "Both @OpenHands and @openhands should be sanitized"
expected = f"Both @{ZWJ}OpenHands and @{ZWJ}openhands should be sanitized"
assert sanitize_openhands_mentions(text) == expected


def test_sanitize_with_punctuation():
"""Test mentions followed by punctuation."""
test_cases = [
("Thanks @OpenHands!", f"Thanks @{ZWJ}OpenHands!"),
("Hello @OpenHands.", f"Hello @{ZWJ}OpenHands."),
("See @OpenHands,", f"See @{ZWJ}OpenHands,"),
("By @OpenHands:", f"By @{ZWJ}OpenHands:"),
("From @OpenHands;", f"From @{ZWJ}OpenHands;"),
("Hi @OpenHands?", f"Hi @{ZWJ}OpenHands?"),
("Use @OpenHands)", f"Use @{ZWJ}OpenHands)"),
("Try (@OpenHands)", f"Try (@{ZWJ}OpenHands)"),
]
for input_text, expected in test_cases:
assert sanitize_openhands_mentions(input_text) == expected


def test_no_sanitize_partial_words():
"""Test that partial word matches are NOT sanitized."""
test_cases = [
"OpenHandsTeam",
"MyOpenHands",
"OpenHandsBot",
"#OpenHands",
]
for text in test_cases:
# Partial words without @ should remain unchanged
assert sanitize_openhands_mentions(text) == text


def test_no_op_cases():
"""Test cases where no sanitization should occur."""
test_cases = [
"",
"No mentions here",
"Just some text",
"@GitHub",
"@Other",
"OpenHands without @",
]
for text in test_cases:
assert sanitize_openhands_mentions(text) == text


def test_sanitize_at_line_boundaries():
"""Test mentions at the start and end of lines."""
test_cases = [
("@OpenHands at start", f"@{ZWJ}OpenHands at start"),
("at end @OpenHands", f"at end @{ZWJ}OpenHands"),
("@OpenHands", f"@{ZWJ}OpenHands"),
]
for input_text, expected in test_cases:
assert sanitize_openhands_mentions(input_text) == expected


def test_sanitize_multiline_text():
"""Test sanitization in multiline text."""
text = """Hello @OpenHands!

This is a test with @openhands mentioned.

Thanks @OPENHANDS for everything!"""

expected = f"""Hello @{ZWJ}OpenHands!

This is a test with @{ZWJ}openhands mentioned.

Thanks @{ZWJ}OPENHANDS for everything!"""

assert sanitize_openhands_mentions(text) == expected


def test_sanitize_with_urls():
"""Test that URLs containing OpenHands are handled correctly."""
test_cases = [
# URL should not be sanitized
("Visit https://github.com/OpenHands", "Visit https://github.com/OpenHands"),
# But mention should be sanitized
(
"See @OpenHands at https://github.com/OpenHands",
f"See @{ZWJ}OpenHands at https://github.com/OpenHands",
),
]
for input_text, expected in test_cases:
assert sanitize_openhands_mentions(input_text) == expected


def test_sanitize_preserves_whitespace():
"""Test that whitespace is preserved correctly."""
text = " @OpenHands \n @openhands "
expected = f" @{ZWJ}OpenHands \n @{ZWJ}openhands "
assert sanitize_openhands_mentions(text) == expected


def test_zwj_constant():
"""Test that ZWJ constant is correctly defined."""
assert ZWJ == "\u200d"
assert len(ZWJ) == 1
assert ord(ZWJ) == 0x200D
Loading