Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
76f103d
feat: support user-defined custom commands in PR welcome message
rnetser Jun 23, 2026
7a20c9e
fix: address Qodo review findings for custom-commands
rnetser Jun 23, 2026
feeedd8
fix: address cycle 2 Qodo findings
rnetser Jun 23, 2026
0a5b691
fix: allow per-repo custom-commands empty list override
rnetser Jun 23, 2026
dfc05bb
fix: load custom-commands via repo-local config
rnetser Jun 23, 2026
f294428
fix: add warning log for non-list custom-commands config
rnetser Jun 23, 2026
6218c54
test: add coverage tests for push_handler, config, ai_cli, and pr_rev…
rnetser Jun 23, 2026
d9d67d1
style: fix ruff formatting in new test files\n\nCo-authored-by: PI (c…
rnetser Jun 23, 2026
34a49f6
style: apply ruff format to all PR files\n\nCo-authored-by: PI (claud…
rnetser Jun 23, 2026
f33f313
fix: address PR review comments
rnetser Jun 24, 2026
cfc9e3e
test: strengthen traceback assertions in push handler ctx tests
rnetser Jun 24, 2026
acc778a
fix: restore handler code lost during rebase and add AGENTS.md docs
rnetser Jun 24, 2026
9200f53
fix: reject empty name/description in custom-command schema
rnetser Jun 24, 2026
e7bdca7
fix: use assert_awaited_once_with for async mock in ai_cli test
rnetser Jun 24, 2026
88ad629
refactor: move custom-commands validation to load time and add markdo…
rnetser Jun 24, 2026
840427d
fix: add log_prefix to all custom-commands validation warnings
rnetser Jun 24, 2026
8c7ad80
docs: add custom-commands section to AGENTS.md
rnetser Jun 24, 2026
6e9055b
fix: address review comments on custom-commands validation
rnetser Jun 25, 2026
b6d899a
style: format test_prepare_retest_welcome_comment.py
rnetser Jun 25, 2026
f0d7465
docs: update sidecar Docker base image version to node:26-slim
rnetser Jun 28, 2026
9f5fcdc
fix: address review findings for custom commands feature
rnetser Jun 28, 2026
21ca200
fix: neutralize @mentions and precise docs escaping claims
rnetser Jun 28, 2026
205a8f5
fix: add backslash escape and schema constraints for custom commands
rnetser Jun 29, 2026
5f884f1
fix: enforce name/description length limits in _validate_custom_commands
rnetser Jun 29, 2026
2ff80b2
fix: reorder validation to prevent log injection of unsafe names
rnetser Jun 29, 2026
ebadda3
fix: use substring assertions for length-limit tests
rnetser Jun 29, 2026
1cf8c26
fix: move length check before regex and add getattr comment
rnetser Jun 29, 2026
77d1609
fix: use fullmatch() to prevent trailing-newline name bypass
rnetser Jun 29, 2026
1d31d06
fix: revert AGENTS.md node version to match main (node:22-slim)\n\nCo…
rnetser Jun 29, 2026
6589c8d
fix: address review comments — remove docs edits, fix validation
rnetser Jun 29, 2026
21770be
fix: add missing built-in commands to BUILTIN_COMMAND_NAMES
rnetser Jun 29, 2026
ce0a834
refactor: use existing constants in BUILTIN_COMMAND_NAMES
rnetser Jun 29, 2026
b0245d2
fix: consistent log prefix and case-insensitive dupe check
rnetser Jun 30, 2026
94e819e
style: fix ruff format in test file\n\nCo-authored-by: PI (claude-opu…
rnetser Jun 30, 2026
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
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ Post-resolution verification: `_verify_cherry_pick_scope()` compares `git diff -

Required env vars for pi-sidecar: `ACPX_AGENTS=cursor`, `VERTEX_CLAUDE_1M=true`, `GOOGLE_APPLICATION_CREDENTIALS`.

### Custom Commands

User-defined commands rendered in the PR welcome message. Documentation-only — the server displays them but does NOT process them. External bots/tools handle them independently.

**Schema:** `webhook_server/config/schema.yaml` (`custom-commands` at global level, `$defs.custom-command-item` for DRY)

**Config:** Three resolution layers (first match wins, no list merge): (1) repo-local `.github-webhook-server.yaml`, (2) `repositories.<repo>.custom-commands` in `config.yaml`, (3) root-level `custom-commands` in `config.yaml`. Per-repo layers can use an empty list (`custom-commands: []`) to disable global defaults; the root-level schema requires `minItems: 1` and `maxItems: 50`.

**Validation:** `GithubWebhook._validate_custom_commands()` in `webhook_server/libs/github_api.py` — validates at load time (entries must be dicts with non-empty `name` (max 100 chars) matching `^[a-zA-Z0-9_-]+$` and non-empty `description` (max 500 chars); duplicate names are rejected, keeping only the first occurrence). Invalid and duplicate entries are logged and skipped.

**Handler:** `PullRequestHandler._prepare_custom_commands_welcome_section` in `webhook_server/libs/handlers/pull_request_handler.py` — renders a "Custom Commands" section with each command as `` * `/{name}` - description ``. Descriptions are markdown-escaped via `_escape_markdown()`.

**Config loading:** `self.custom_commands` loaded in `GithubWebhook._repo_data_from_config()` via `self.config.get_value("custom-commands", ...)` with `extra_dict=repository_config` for per-repo override support.

### Sidecar Architecture

`sidecar-helper/` — Node.js pi-sidecar bridge for AI provider integration. Minimal TypeScript wrapper importing `@myk-org/pi-sidecar`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A [FastAPI](https://fastapi.tiangolo.com)-based webhook server for automating Gi
- **OWNERS-Based Permissions** — reviewer and approver assignment from OWNERS files with per-directory granularity
- **Container and PyPI Publishing** — automated container builds, tag-based releases, and PyPI publishing
- **Issue Comment Commands** — `/retest`, `/approve`, `/cherry-pick`, `/build-and-push-container`, and more
- **Custom Commands** — user-defined documentation-only commands rendered in the PR welcome message
- **AI Features** — conventional commit title validation and suggestions via Claude, Gemini, or Cursor
- **Repository Bootstrap** — automatic label creation, branch protection, and webhook configuration on startup
- **Log Viewer** — real-time log streaming, webhook flow visualization, and structured log analysis
Expand Down
9 changes: 9 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,12 @@ repositories:
# - ".github/workflows/"
# - ".github/actions/"
# committer-identity-check: true

# Custom commands to display in PR welcome message (documentation-only)
# These commands are shown in the welcome comment but not processed by the server.
# External bots or tools handle them independently.
# custom-commands:
# - name: deploy-staging
# description: Deploy this PR to the staging environment
# - name: run-e2e
# description: Trigger end-to-end test suite against this PR
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,4 @@ dev = [
"types-pyyaml>=6.0.12.20250516",
"types-requests>=2.32.4.20250611",
]
tests = ["psutil>=7.0.0", "pytest-asyncio>=0.26.0", "pytest-xdist>=3.7.0"]
tests = ["jsonschema>=4.0.0", "psutil>=7.0.0", "pytest-asyncio>=0.26.0", "pytest-xdist>=3.7.0"]
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ $defs:
- ai-provider
- ai-model
additionalProperties: false
custom-command-item:
type: object
properties:
name:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
minLength: 1
maxLength: 100
description: Command name (without the leading slash)
description:
type: string
minLength: 1
maxLength: 500
description: Human-readable description of what the command does
required:
- name
- description
Comment thread
rnetser marked this conversation as resolved.
additionalProperties: false
security-checks:
type: object
description: |
Expand Down Expand Up @@ -253,6 +271,16 @@ properties:
$ref: '#/$defs/ai-features'
security-checks:
$ref: '#/$defs/security-checks'
custom-commands:
Comment thread
rnetser marked this conversation as resolved.
Comment thread
rnetser marked this conversation as resolved.
type: array
minItems: 1
maxItems: 50
description: |
Custom commands to display in the PR welcome message (global default).
These are documentation-only - the server renders them in the welcome
message but does NOT process them. External bots or tools handle them.
items:
$ref: '#/$defs/custom-command-item'
labels:
type: object
description: Configure which labels are enabled and their colors
Expand Down Expand Up @@ -716,3 +744,20 @@ properties:
- name
- command
additionalProperties: false
custom-commands:
type: array
maxItems: 50
description: |
Comment thread
rnetser marked this conversation as resolved.
Custom commands to display in the PR welcome message (per-repo override).
These are documentation-only - the server renders them in the welcome
message but does NOT process them. External bots or tools handle them.
An empty list is allowed to explicitly disable global custom-commands
for this repository.

Examples:
- name: deploy-staging
description: Deploy this PR to the staging environment
- name: run-e2e
description: Trigger end-to-end test suite against this PR
items:
$ref: '#/$defs/custom-command-item'
100 changes: 100 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from webhook_server.utils.constants import (
BUILD_CONTAINER_STR,
BUILTIN_CHECK_NAMES,
BUILTIN_COMMAND_NAMES,
CAN_BE_MERGED_STR,
CONFIGURABLE_LABEL_CATEGORIES,
CONVENTIONAL_TITLE_STR,
Expand Down Expand Up @@ -941,6 +942,11 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
)
self.custom_check_runs: list[dict[str, Any]] = self._validate_custom_check_runs(raw_custom_checks)

raw_custom_commands = self.config.get_value(
value="custom-commands", return_on_none=[], extra_dict=repository_config
)
self.custom_commands: list[dict[str, str]] = self._validate_custom_commands(raw_custom_commands)

_auto_users = self.config.get_value(
value="auto-verified-and-merged-users", return_on_none=[], extra_dict=repository_config
)
Expand Down Expand Up @@ -1596,6 +1602,100 @@ def _validate_custom_check_runs(self, raw_checks: object) -> list[dict[str, Any]

return validated_checks

def _validate_custom_commands(self, raw_commands: object) -> list[dict[str, str]]:
"""Validate custom commands configuration at load time.

Validates each custom command entry and returns only valid ones:
- Checks that entry is a dict with 'name' and 'description' string fields
- Verifies name matches the safe pattern [a-zA-Z0-9_-]+
- Logs warnings for invalid entries and skips them

Args:
raw_commands: Custom command configurations from config (should be a list)

Returns:
List of validated custom command configurations
"""
if not isinstance(raw_commands, list):
Comment thread
rnetser marked this conversation as resolved.
prefix = getattr(self, "log_prefix", "")
log_msg_prefix = f"{prefix} " if prefix else ""
self.logger.warning(
f"{log_msg_prefix}custom-commands config is not a list (got {type(raw_commands).__name__}), skipping"
)
return []

# Use getattr since log_prefix may not be set during early __init__ calls
prefix = getattr(self, "log_prefix", "")
Comment thread
rnetser marked this conversation as resolved.
log_msg_prefix = f"{prefix} " if prefix else ""
safe_name_pattern = re.compile(r"^[a-zA-Z0-9_-]+$")
seen_names: set[str] = set()
validated: list[dict[str, str]] = []

for cmd in raw_commands:
if not isinstance(cmd, dict):
self.logger.warning(f"{log_msg_prefix}Custom command entry is not a mapping, skipping")
continue
Comment thread
rnetser marked this conversation as resolved.

name = cmd.get("name")
description = cmd.get("description")
if not isinstance(name, str) or not name:
self.logger.warning(f"{log_msg_prefix}Custom command missing or invalid 'name', skipping")
continue

if len(name) > 100:
self.logger.warning(
f"{log_msg_prefix}Custom command name {name[:20]!r}... exceeds 100 characters, skipping"
)
continue

if not safe_name_pattern.fullmatch(name):
self.logger.warning(
f"{log_msg_prefix}Custom command name {name!r} does not match safe pattern, skipping"
)
continue

if name.lower() in BUILTIN_COMMAND_NAMES:
Comment thread
rnetser marked this conversation as resolved.
self.logger.warning(
f"{log_msg_prefix}Custom command name {name!r} collides with built-in command, skipping"
)
continue
Comment thread
rnetser marked this conversation as resolved.

if not isinstance(description, str) or not description:
self.logger.warning(
f"{log_msg_prefix}Custom command '{name}' missing or invalid 'description', skipping"
)
continue

if len(description) > 500:
self.logger.warning(
f"{log_msg_prefix}Custom command '{name}' description exceeds 500 characters, skipping"
)
continue
Comment thread
rnetser marked this conversation as resolved.

if name.lower() in seen_names:
self.logger.warning(
f"{log_msg_prefix}Duplicate custom command name {name!r} (case-insensitive), skipping"
)
continue
seen_names.add(name.lower())

validated.append({"name": name, "description": description})
Comment thread
rnetser marked this conversation as resolved.

if validated:
self.logger.info(
f"{log_msg_prefix}Loaded {len(validated)} custom command(s): {[c['name'] for c in validated]}"
)
if raw_commands and not validated:
self.logger.warning(
f"{log_msg_prefix}No valid custom commands loaded — all {len(raw_commands)} entries were invalid"
)
elif len(validated) < len(raw_commands):
self.logger.warning(
f"{log_msg_prefix}Skipped {len(raw_commands) - len(validated)} invalid custom command(s)"
)

return validated

def __del__(self) -> None:
"""Remove the shared clone directory when the webhook object is destroyed.

Expand Down
39 changes: 39 additions & 0 deletions webhook_server/libs/handlers/pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ def _prepare_welcome_comment(self) -> str:
{self._prepare_retest_welcome_comment}
{self._prepare_container_operations_welcome_section}\
{self._prepare_cherry_pick_section}\
{self._prepare_custom_commands_welcome_section}\

#### Label Management
* `/<label-name>` - Add a label to the PR
Expand Down Expand Up @@ -842,6 +843,44 @@ def _prepare_cherry_pick_section(self) -> str:
"""
return "\n#### Branch Management\n* `/rebase` - Rebase this PR branch onto its base branch\n"

@staticmethod
def _escape_markdown(text: str) -> str:
"""Escape markdown special characters in text.

Prevents markdown injection when inserting user-provided text
into PR comments. Escapes backslashes first to avoid interaction
with subsequently escaped characters, then escapes characters that
could create links, images, inline code, bold, underline,
strikethrough, or HTML tags, and neutralizes @mentions.
"""
text = text.replace("\\", "\\\\")
for char in ("[", "]", "(", ")", "!", "`", "*", "_", "~", "<", ">"):
Comment thread
rnetser marked this conversation as resolved.
text = text.replace(char, f"\\{char}")
# Neutralize @mentions to prevent unintended user/team pings
text = text.replace("@", "@\u200b")
return text

@property
def _prepare_custom_commands_welcome_section(self) -> str:
"""Prepare the Custom Commands section for the welcome comment.

Renders user-defined custom commands from configuration.
These are documentation-only - the server does not process them.
Commands are validated at load time by GithubWebhook._validate_custom_commands().
"""
custom_commands: list[dict[str, str]] = self.github_webhook.custom_commands
if not custom_commands:
return ""

lines: list[str] = ["\n#### Custom Commands"]
for cmd in custom_commands:
Comment thread
rnetser marked this conversation as resolved.
name = cmd["name"]
description = cmd["description"]
sanitized = self._escape_markdown(description.replace("\n", " ").replace("\r", " "))
lines.append(f"* `/{name}` - {sanitized}")

return "\n".join(lines) + "\n"

async def label_all_opened_pull_requests_merge_state_after_merged(self) -> None:
"""
Labels pull requests based on their mergeable state.
Expand Down
63 changes: 62 additions & 1 deletion webhook_server/tests/test_ai_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

from __future__ import annotations

from webhook_server.libs.ai_cli import get_ai_config
from unittest.mock import AsyncMock, patch

import pytest

from webhook_server.libs.ai_cli import AIResult, call_ai, get_ai_config


class TestGetAiConfig:
Expand All @@ -23,3 +27,60 @@ def test_get_ai_config_partial_missing_model(self) -> None:

def test_get_ai_config_partial_missing_provider(self) -> None:
assert get_ai_config({"ai-model": "sonnet"}) is None


class TestCallAi:
"""Test suite for call_ai function."""

@pytest.mark.asyncio
async def test_call_ai_sidecar_unavailable(self) -> None:
"""Test call_ai returns error when sidecar is unavailable."""
with patch(
"webhook_server.libs.ai_cli.check_sidecar_available",
new_callable=AsyncMock,
return_value=(False, "connection refused"),
):
result = await call_ai(
prompt="test",
ai_provider="claude",
ai_model="sonnet",
cwd="/tmp",
)
assert result.success is False
assert "Pi-sidecar unavailable" in result.error
assert "connection refused" in result.error

@pytest.mark.asyncio
async def test_call_ai_sidecar_available(self) -> None:
"""Test call_ai delegates to call_ai_once when sidecar is available."""
expected = AIResult(success=True, text="hello", error="")
with patch(
"webhook_server.libs.ai_cli.check_sidecar_available",
new_callable=AsyncMock,
return_value=(True, "ok"),
):
with patch(
"webhook_server.libs.ai_cli.call_ai_once",
new_callable=AsyncMock,
return_value=expected,
) as mock_call:
result = await call_ai(
prompt="test",
ai_provider="claude",
ai_model="sonnet",
cwd="/tmp",
timeout_minutes=5,
system_prompt="be helpful",
)
assert result.success is True
assert result.text == "hello"
mock_call.assert_awaited_once_with(
prompt="test",
ai_provider="claude",
ai_model="sonnet",
cwd="/tmp",
ai_call_timeout=5,
system_prompt="be helpful",
tools=None,
custom_tools=None,
)
Comment thread
rnetser marked this conversation as resolved.
Loading