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
13 changes: 13 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ auto-verify-cherry-picked-prs: true # Default: true - automatically verify cher

create-issue-for-new-pr: true # Global default: create tracking issues for new PRs

# Commands allowed on draft PRs (optional)
# If not set: commands are blocked on draft PRs (default behavior)
# If empty list []: all commands allowed on draft PRs
# If list with values: only those commands allowed on draft PRs
# allow-commands-on-draft-prs: [] # Uncomment to allow all commands on draft PRs
# allow-commands-on-draft-prs: # Or allow only specific commands:
# - build-and-push-container
# - retest

# Labels configuration - control which labels are enabled and their colors
# If not set, all labels are enabled with default colors
labels:
Expand Down Expand Up @@ -186,6 +195,10 @@ repositories:
minimum-lgtm: 0 # The minimum PR lgtm required before approve the PR
create-issue-for-new-pr: true # Override global setting: create tracking issues for new PRs (default: true)

# Allow commands on draft PRs (overrides global setting)
allow-commands-on-draft-prs:
- build-and-push-container # Allow building containers on draft PRs

# Repository-specific labels configuration (overrides global)
labels:
enabled-labels:
Expand Down
18 changes: 18 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests (global default)
default: true
allow-commands-on-draft-prs:
type: array
items:
type: string
description: |
List of commands allowed on draft PRs.
- Not set (default): commands blocked on draft PRs
- Empty list []: all commands allowed on draft PRs
- List with commands: only specified commands allowed (e.g., ["build-and-push-container", "retest"])
labels:
type: object
description: Configure which labels are enabled and their colors
Expand Down Expand Up @@ -332,6 +341,15 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests
default: true
allow-commands-on-draft-prs:
type: array
items:
type: string
description: |
List of commands allowed on draft PRs.
- Not set (default): commands blocked on draft PRs
- Empty list []: all commands allowed on draft PRs
- List with commands: only specified commands allowed (e.g., ["build-and-push-container", "retest"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
labels:
type: object
description: Configure which labels are enabled and their colors
Expand Down
31 changes: 24 additions & 7 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,14 +472,31 @@ async def process(self) -> Any:
self.logger.debug(f"{self.log_prefix} {event_log}")

if await asyncio.to_thread(lambda: pull_request.draft):
self.logger.debug(f"{self.log_prefix} Pull request is draft, doing nothing")
token_metrics = await self._get_token_metrics()
self.logger.info(
f"{self.log_prefix} Webhook processing completed successfully: "
f"draft PR (skipped) - {token_metrics}",
allow_commands_on_draft = self.config.get_value("allow-commands-on-draft-prs")

# Validate type: must be a list, treat invalid types as None (default-deny)
if allow_commands_on_draft is not None and not isinstance(allow_commands_on_draft, list):
self.logger.warning(
f"{self.log_prefix} allow-commands-on-draft-prs has invalid type "
f"{type(allow_commands_on_draft).__name__}, expected list. Treating as not configured."
)
allow_commands_on_draft = None

# Only allow issue_comment events when config is explicitly set
if allow_commands_on_draft is None or self.github_event != "issue_comment":
self.logger.debug(f"{self.log_prefix} Pull request is draft, doing nothing")
token_metrics = await self._get_token_metrics()
self.logger.info(
f"{self.log_prefix} Webhook processing completed successfully: "
f"draft PR (skipped) - {token_metrics}",
)
await self._update_context_metrics()
return None

self.logger.debug(
f"{self.log_prefix} Pull request is draft, but allow-commands-on-draft-prs is "
"configured, processing issue_comment"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
await self._update_context_metrics()
return None

self.last_commit = await self._get_last_commit(pull_request=pull_request)
self.parent_committer = pull_request.user.login
Expand Down
31 changes: 30 additions & 1 deletion webhook_server/libs/handlers/issue_comment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ async def process_comment_webhook_data(self, pull_request: PullRequest) -> None:

# Execute all commands in parallel
if _user_commands:
# Cache draft status once to avoid repeated API calls
is_draft = await asyncio.to_thread(lambda: pull_request.draft)

tasks: list[Coroutine[Any, Any, Any] | Task[Any]] = []
for user_command in _user_commands:
task = asyncio.create_task(
Expand All @@ -98,6 +101,7 @@ async def process_comment_webhook_data(self, pull_request: PullRequest) -> None:
command=user_command,
reviewed_user=user_login,
issue_comment_id=self.hook_data["comment"]["id"],
is_draft=is_draft,
)
)
tasks.append(task)
Expand Down Expand Up @@ -143,7 +147,7 @@ async def process_comment_webhook_data(self, pull_request: PullRequest) -> None:
raise

async def user_commands(
self, pull_request: PullRequest, command: str, reviewed_user: str, issue_comment_id: int
self, pull_request: PullRequest, command: str, reviewed_user: str, issue_comment_id: int, *, is_draft: bool
) -> None:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
available_commands: list[str] = [
COMMAND_RETEST_STR,
Expand All @@ -161,6 +165,31 @@ async def user_commands(
_command = command_and_args[0]
_args: str = command_and_args[1] if len(command_and_args) > 1 else ""

# Check if command is allowed on draft PRs
if is_draft:
allow_commands_on_draft = self.github_webhook.config.get_value("allow-commands-on-draft-prs")
if not isinstance(allow_commands_on_draft, list):
self.logger.debug(
f"{self.log_prefix} Command {_command} blocked: "
"draft PR and allow-commands-on-draft-prs not configured"
)
return
# Empty list means all commands allowed; non-empty list means only those commands
if len(allow_commands_on_draft) > 0:
# Sanitize: ensure all entries are strings for safe join and comparison
allow_commands_on_draft = [str(cmd) for cmd in allow_commands_on_draft]
if _command not in allow_commands_on_draft:
self.logger.debug(
f"{self.log_prefix} Command {_command} is not allowed on draft PRs. "
f"Allowed commands: {allow_commands_on_draft}"
)
await asyncio.to_thread(
pull_request.create_issue_comment,
f"Command `/{_command}` is not allowed on draft PRs.\n"
f"Allowed commands on draft PRs: {', '.join(allow_commands_on_draft)}",
)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.logger.debug(
f"{self.log_prefix} User: {reviewed_user}, Command: {_command}, Command args: {_args or 'None'}"
)
Expand Down
76 changes: 76 additions & 0 deletions webhook_server/tests/test_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def valid_full_config(self) -> dict[str, Any]:
"branch-protection": {"strict": False, "required_approving_review_count": 1},
"set-auto-merge-prs": ["main"],
"can-be-merged-required-labels": ["ready"],
"allow-commands-on-draft-prs": ["wip", "hold"],
"conventional-title": "feat,fix,docs",
"minimum-lgtm": 2,
"custom-check-runs": [
Expand Down Expand Up @@ -140,6 +141,9 @@ def test_valid_full_config_loads(self, valid_full_config: dict[str, Any], monkey
assert repo_data["minimum-lgtm"] == 2
assert repo_data["conventional-title"] == "feat,fix,docs"

# Test allow-commands-on-draft-prs
assert repo_data["allow-commands-on-draft-prs"] == ["wip", "hold"]

# Test custom-check-runs structure
custom_check_runs = repo_data["custom-check-runs"]
assert len(custom_check_runs) == 2
Expand Down Expand Up @@ -739,3 +743,75 @@ def test_labels_empty_enabled_labels(self, monkeypatch: pytest.MonkeyPatch) -> N
assert config.root_data["labels"]["enabled-labels"] == []
finally:
shutil.rmtree(temp_dir)

def test_allow_commands_on_draft_prs_valid_array(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that allow-commands-on-draft-prs accepts a valid array of command strings at global level."""
config = valid_minimal_config.copy()
config["allow-commands-on-draft-prs"] = ["build-and-push-container", "retest", "wip"]

temp_dir = self.create_temp_config_dir_and_data(config)

try:
monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir)

config_obj = Config()
assert config_obj.root_data["allow-commands-on-draft-prs"] == [
"build-and-push-container",
"retest",
"wip",
]
finally:
shutil.rmtree(temp_dir)

def test_allow_commands_on_draft_prs_empty_array(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that empty allow-commands-on-draft-prs array is valid (allows all commands on draft PRs)."""
config = valid_minimal_config.copy()
config["allow-commands-on-draft-prs"] = []

temp_dir = self.create_temp_config_dir_and_data(config)

try:
monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir)

config_obj = Config()
assert config_obj.root_data["allow-commands-on-draft-prs"] == []
finally:
shutil.rmtree(temp_dir)

def test_allow_commands_on_draft_prs_omitted(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that omitting allow-commands-on-draft-prs is valid (default: commands blocked on draft PRs)."""
config = valid_minimal_config.copy()

temp_dir = self.create_temp_config_dir_and_data(config)

try:
monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir)

config_obj = Config()
assert "allow-commands-on-draft-prs" not in config_obj.root_data
finally:
shutil.rmtree(temp_dir)

def test_allow_commands_on_draft_prs_repository_level(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that allow-commands-on-draft-prs works at repository level."""
config = valid_minimal_config.copy()
config["repositories"]["test-repo"]["allow-commands-on-draft-prs"] = ["hold", "retest"]

temp_dir = self.create_temp_config_dir_and_data(config)

try:
monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir)

config_obj = Config()
repo_data = config_obj.root_data["repositories"]["test-repo"]
assert repo_data["allow-commands-on-draft-prs"] == ["hold", "retest"]
finally:
shutil.rmtree(temp_dir)
Loading