Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9048834
feat: support user-defined custom commands in PR welcome message
rnetser Jun 23, 2026
9ffaf4f
fix: address Qodo review findings for custom-commands
rnetser Jun 23, 2026
5e69b62
fix: address cycle 2 Qodo findings
rnetser Jun 23, 2026
0bd51f3
fix: allow per-repo custom-commands empty list override
rnetser Jun 23, 2026
dbd5785
fix: load custom-commands via repo-local config
rnetser Jun 23, 2026
4909358
fix: add warning log for non-list custom-commands config
rnetser Jun 23, 2026
c67389b
test: add coverage tests for push_handler, config, ai_cli, and pr_rev…
rnetser Jun 23, 2026
8b5f205
style: fix ruff formatting in new test files\n\nCo-authored-by: PI (c…
rnetser Jun 23, 2026
1b46cd1
style: apply ruff format to all PR files\n\nCo-authored-by: PI (claud…
rnetser Jun 23, 2026
3fe865d
fix: address PR review comments
rnetser Jun 24, 2026
3e232f3
test: strengthen traceback assertions in push handler ctx tests
rnetser Jun 24, 2026
a8e2782
fix: restore handler code lost during rebase and add AGENTS.md docs
rnetser Jun 24, 2026
73d3baa
fix: reject empty name/description in custom-command schema
rnetser Jun 24, 2026
b271db2
fix: use assert_awaited_once_with for async mock in ai_cli test
rnetser Jun 24, 2026
542bac6
refactor: move custom-commands validation to load time and add markdo…
rnetser Jun 24, 2026
9290524
fix: add log_prefix to all custom-commands validation warnings
rnetser Jun 24, 2026
97abc9a
docs: add custom-commands section to AGENTS.md
rnetser Jun 24, 2026
7f2fdb4
docs: fix custom-commands AGENTS.md inaccuracies
rnetser Jun 24, 2026
18a055d
docs: document all 3 config resolution layers for custom-commands
rnetser Jun 24, 2026
1e0486a
docs: clarify empty list only valid at per-repo layers
rnetser Jun 24, 2026
003eeb1
fix: address review comments on custom-commands validation
rnetser Jun 25, 2026
36c6adc
style: format test_prepare_retest_welcome_comment.py
rnetser Jun 25, 2026
6b8ee6e
docs: update sidecar Docker base image version to node:26-slim
rnetser Jun 28, 2026
d25d699
fix: address review findings for custom commands feature
rnetser Jun 28, 2026
feb76b6
fix: neutralize @mentions and precise docs escaping claims
rnetser Jun 28, 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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,20 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo).
- `VERTEX_CLAUDE_1M=true` — enables Claude 1M context window models via Vertex AI
- `GOOGLE_APPLICATION_CREDENTIALS` — already set for Vertex AI access

### 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`.

**Validation:** `GithubWebhook._validate_custom_commands()` in `webhook_server/libs/github_api.py` — validates at load time (entries must be dicts with non-empty `name` matching `^[a-zA-Z0-9_-]+$` and non-empty `description`; 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 that provides AI provider integration (cherry-pick conflict resolution, conventional title suggestions). Contains a minimal TypeScript wrapper that imports and starts the `@myk-org/pi-sidecar` server.
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
2 changes: 2 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ The following keys are written under `repositories.<short-repo-name>` in `config
- `custom-check-runs[].name`: Custom GitHub check-run name. It must be unique, use only safe characters, and not collide with built-in names such as `tox`, `pre-commit`, `build-container`, `python-module-install`, `conventional-title`, or `can-be-merged`.
- `custom-check-runs[].command`: Command run in the repository worktree. Environment-variable prefixes and multiline commands are supported, but the executable must exist on the server.
- `custom-check-runs[].mandatory`: Whether the custom check must pass for mergeability. Default is `true`. `false` checks still run; they just stop gating merges.
- `custom-commands[].name`: Name for a documentation-only command rendered in the PR welcome message. Must match `^[a-zA-Z0-9_-]+$`. Rendered as `/<name>` in the welcome comment.
- `custom-commands[].description`: Human-readable description shown next to the command in the welcome comment. Markdown formatting characters (`*`, `_`, `~`, `` ` ``), link/image syntax (`[`, `]`, `(`, `)`, `!`), HTML angle brackets (`<`, `>`), and `@` mentions are automatically neutralized.
- `test-oracle.server-url`, `test-oracle.ai-provider`, `test-oracle.ai-model`, `test-oracle.test-patterns`, `test-oracle.triggers`: Same meanings as the global `test-oracle` keys. A repository-level object replaces the global object for that repository.
- `ai-features.ai-provider`, `ai-features.ai-model`, `ai-features.conventional-title.enabled`, `ai-features.conventional-title.mode`, `ai-features.conventional-title.timeout-minutes`, `ai-features.resolve-cherry-pick-conflicts-with-ai.enabled`, `ai-features.resolve-cherry-pick-conflicts-with-ai.timeout-minutes`: Same meanings as the global `ai-features` keys. A repository-level object replaces the global object for that repository.

Expand Down
27 changes: 27 additions & 0 deletions docs/pull-request-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,33 @@ else:

> **Tip:** If you rely on cherry-pick automation, keep the `cherry-pick` label category enabled and set `cherry-pick-assign-to-pr-author: true` if you want the follow-up PR to land on the original author by default.

## Custom Commands

Custom commands let you document repository-specific workflows directly in the PR welcome comment. Unlike `custom-check-runs`, these are **documentation-only** — the server renders them in the welcome message but does not execute anything when they are invoked.

Each command has a `name` and a `description`. Names must match the pattern `^[a-zA-Z0-9_-]+$` and are rendered as `/<name>` in the welcome comment. Descriptions are escaped to prevent markdown injection.

```yaml
# In config.yaml (global) or .github-webhook-server.yaml (per-repo)
custom-commands:
- name: run-tests
description: Run the full test suite locally before merging
- name: deploy-staging
description: Deploy this PR to the staging environment for review
```

The welcome comment renders a **Custom Commands** section:

```
#### Custom Commands
- `/run-tests` — Run the full test suite locally before merging
- `/deploy-staging` — Deploy this PR to the staging environment for review
```

**Validation:** Commands are validated at load time. Invalid entries (missing name/description, unsafe name characters, duplicates) are skipped with a warning. If all entries are invalid, a summary warning is logged.

> **Tip:** Custom commands are reloaded on every webhook event — no server restart needed. Update the config file and use `/regenerate-welcome` to refresh the PR comment.

## Key Configuration

Most PR automation settings can be defined globally in `config.yaml`. Many of the same keys can also be overridden per repository in `.github-webhook-server.yaml`.
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.

41 changes: 41 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ $defs:
- ai-provider
- ai-model
additionalProperties: false
custom-command-item:
type: object
properties:
name:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
minLength: 1
description: Command name (without the leading slash)
description:
type: string
minLength: 1
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 +269,15 @@ properties:
$ref: '#/$defs/ai-features'
security-checks:
$ref: '#/$defs/security-checks'
custom-commands:
Comment thread
rnetser marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] No maxLength/maxItems constraints on custom-commands

The schema has no maxLength on description or maxItems on the custom-commands array. Extremely long descriptions or thousands of commands could produce a welcome comment exceeding GitHub's 65,536-character limit, causing the create_issue_comment API call to fail.

Suggestion: Consider adding maxItems: 50 on the array and maxLength: 500 on description for robustness. Low priority — consistent with custom-check-runs also lacking these limits.

type: array
minItems: 1
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 +741,19 @@ properties:
- name
- command
additionalProperties: false
custom-commands:
type: array
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'
69 changes: 69 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,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 +1601,70 @@ 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):
if raw_commands is not None:
prefix = getattr(self, "log_prefix", "")
self.logger.warning(
f"{prefix} custom-commands config is not a list (got {type(raw_commands).__name__}), skipping"
)
return []

prefix = getattr(self, "log_prefix", "")
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"{prefix} Custom command entry is not a mapping, skipping")
continue

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

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

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

if name in seen_names:
self.logger.warning(f"{prefix} Custom command name {name!r} is duplicated, skipping")
continue
Comment thread
rnetser marked this conversation as resolved.
seen_names.add(name)

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

if validated:
self.logger.info(f"{prefix} Loaded {len(validated)} custom command(s): {[c['name'] for c in validated]}")
if raw_commands and not validated:
self.logger.warning(
f"{prefix} No valid custom commands loaded — all {len(raw_commands)} entries were invalid"
)
elif len(validated) < len(raw_commands):
self.logger.warning(f"{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
37 changes: 37 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,42 @@ 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 characters that could create links,
images, inline code, bold, underline, strikethrough, or HTML tags,
and neutralizes @mentions.
"""
for char in ("[", "]", "(", ")", "!", "`", "*", "_", "~", "<", ">"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] _escape_markdown missing backslash escape

The backslash \ is itself a markdown escape character but isn't in the escaped set. A description like use \*asterisks\* would produce use \\\*asterisks\\\* — but since \ isn't escaped first, the backslash passes through and may interact with the escaped chars.

Suggestion: Add \ as the first character to escape (before all others):

text = text.replace("\\", "\\\\")

Low risk since descriptions come from admin config.

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