Skip to content
Open
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Handlers in `webhook_server/libs/handlers/` process events; config via YAML with
See `docs/` for generated architecture docs (regenerate with docsfy β€” **NEVER edit `docs/` manually**).

- Stack: Python 3.13, FastAPI, PyGithub, gql, aiohttp
- Internal APIs β€” no backward compat; only `config.yaml`, `.github-webhook-server.yaml`, and webhook payloads are stable
- Internal APIs β€” no backward compat; only `config.yaml`, `.github-webhook-server.yaml`, `.github-webhook-server-welcome-message.md`, and webhook payloads are stable
- Config: `webhook_server/libs/config.py` (schema: `webhook_server/config/schema.yaml`)
- GitHub API: `webhook_server/libs/github_api.py` β€” PyGithub REST v3, multi-token failover
- Log viewer: `webhook_server/web/log_viewer.py` β€” WebSocket streaming
Expand Down Expand Up @@ -61,7 +61,8 @@ pr.create_issue_comment("Comment")
### Anti-Defensive Programming: Fail-Fast
```python
# ❌ WRONG β€” config is required, ALWAYS provided
Comment thread
rnetser marked this conversation as resolved.
if self.config: value = self.config.get_value("key")
if self.config:
value = self.config.get_value("key")

# βœ… CORRECT β€” fail-fast; KeyError = legitimate bug
value = self.config.get_value("key")
Expand Down
6 changes: 6 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ create-issue-for-new-pr: true # Global default: create tracking issues for new P

cherry-pick-assign-to-pr-author: true # Default: true - assign cherry-pick PRs to the original PR author

# Additional information to display at the end of PR welcome messages (markdown)
# welcome-extra-info: |
# **Note:** Please review the contribution guide before merging.
# - Ensure tests pass
# - Update documentation if needed

# 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
Expand Down
11 changes: 11 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ $defs:
items:
type: string
additionalProperties: false
welcome-extra-info:
type: string
maxLength: 10240
description: |
Comment thread
rnetser marked this conversation as resolved.
Additional information to display at the end of the PR welcome message.
Content is injected as-is (markdown).
An empty string explicitly clears any inherited value.
type: object
properties:
log-level:
Expand Down Expand Up @@ -251,6 +258,8 @@ properties:
additionalProperties: false
ai-features:
$ref: '#/$defs/ai-features'
welcome-extra-info:
Comment thread
rnetser marked this conversation as resolved.
$ref: '#/$defs/welcome-extra-info'
security-checks:
$ref: '#/$defs/security-checks'
labels:
Expand Down Expand Up @@ -523,6 +532,8 @@ properties:
items:
type: string
description: Required labels for PR to be marked as can-be-merged
welcome-extra-info:
$ref: '#/$defs/welcome-extra-info'
conventional-title:
type: string
description: |
Expand Down
71 changes: 71 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import httpx
from github import GithubException
from github.Commit import Commit
from github.GithubException import UnknownObjectException
from github.PullRequest import PullRequest
from github.Repository import Repository
from starlette.datastructures import Headers
Expand Down Expand Up @@ -62,6 +63,8 @@
)

_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
_WELCOME_EXTRA_INFO_MAX_BYTES: int = 10240
_WELCOME_EXTRA_INFO_FILENAME: str = ".github-webhook-server-welcome-message.md"


class CountingRequester:
Expand Down Expand Up @@ -142,6 +145,7 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.
self.github_api: github.Github | None = None
self.initial_rate_limit_remaining: int | None = None
self.requester_wrapper: CountingRequester | None = None
self.welcome_extra_info: str = ""

if not self.config.repository_data:
raise RepositoryNotFoundInConfigError(f"Repository {self.repository_name} not found in config file")
Expand Down Expand Up @@ -689,6 +693,12 @@ async def process(self) -> Any:
github_api_call(lambda: pull_request.head.sha, logger=self.logger, log_prefix=self.log_prefix),
)

if self.github_event == "pull_request" and self.hook_data.get("action") in (
"opened",
"ready_for_review",
):
await self.load_welcome_extra_info_from_file()
Comment thread
rnetser marked this conversation as resolved.

Comment on lines +696 to +701

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Duplicate file fetch 🐞 Bug ➹ Performance

GithubWebhook.process() preloads .github-webhook-server-welcome-message.md on pull_request
opened/ready_for_review, but PullRequestHandler.process_new_or_reprocess_pull_request() loads the
same file again when creating the welcome comment. This causes an avoidable second get_contents()
call on the common path where a welcome comment is created (including draft→ready_for_review).
Agent Prompt
### Issue description
The welcome-extra-info markdown file is fetched twice during welcome-comment creation for `pull_request` `opened` / `ready_for_review` events: once in `GithubWebhook.process()` and again in `PullRequestHandler.process_new_or_reprocess_pull_request()`.

### Issue Context
The handler already loads the file only when it is about to build a welcome message (and only when a welcome comment doesn’t exist). The earlier preload in `GithubWebhook.process()` is therefore redundant for these paths and costs an extra GitHub API call.

### Fix Focus Areas
- webhook_server/libs/github_api.py[696-701]
- webhook_server/libs/handlers/pull_request_handler.py[1975-1980]

### Implementation guidance
Choose one of:
1) Remove the preload from `GithubWebhook.process()` and rely on the handler(s) to call `load_welcome_extra_info_from_file()` only when building/regenerating the welcome message.
2) Add a small memoization flag on `GithubWebhook` (e.g., `welcome_extra_info_file_checked: bool`) so subsequent calls within the same webhook instance become no-ops.

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

# Clone repository for local file processing (OWNERS, changed files)
# For check_run, status, and pull_request_review_thread events,
# cloning happens later only when needed (inside their respective handlers)
Expand Down Expand Up @@ -1109,6 +1119,23 @@ def _sanitize_item(item: Any) -> str:

self.mask_sensitive = self.config.get_value("mask-sensitive-data", return_on_none=True)

self.welcome_extra_info = self.config.get_value(
"welcome-extra-info", return_on_none="", extra_dict=repository_config
)
Comment thread
rnetser marked this conversation as resolved.

_prefix = getattr(self, "log_prefix", "")
if not isinstance(self.welcome_extra_info, str):
Comment thread
rnetser marked this conversation as resolved.
_type = type(self.welcome_extra_info).__name__
self.logger.warning(f"{_prefix} welcome-extra-info must be a string, got {_type}. Ignoring.")
self.welcome_extra_info = ""
else:
_byte_len = len(self.welcome_extra_info.encode("utf-8"))
if _byte_len > _WELCOME_EXTRA_INFO_MAX_BYTES:
_max = _WELCOME_EXTRA_INFO_MAX_BYTES
_msg = f"welcome-extra-info exceeds {_max}-byte limit ({_byte_len} bytes). Ignoring."
self.logger.warning(f"{_prefix} {_msg}")
self.welcome_extra_info = ""

async def _build_trusted_committers(self, api_users: list[str | None] | None = None) -> None:
"""Add dynamic entries to trusted-committers list.

Expand Down Expand Up @@ -1142,6 +1169,50 @@ async def _build_trusted_committers(self, api_users: list[str | None] | None = N

self.logger.debug(f"{self.log_prefix} Trusted committers: {self.security_trusted_committers}")

async def load_welcome_extra_info_from_file(self) -> None:
"""Load welcome extra info from the repo-level welcome message file.

This file takes priority over all config-based welcome-extra-info settings.
File must be UTF-8 and at most _WELCOME_EXTRA_INFO_MAX_BYTES bytes (10 KB).
"""
try:
_path = await github_api_call(
lambda: self.repository.get_contents(_WELCOME_EXTRA_INFO_FILENAME),
logger=self.logger,
log_prefix=self.log_prefix,
)
content_file = _path[0] if isinstance(_path, list) else _path

file_size = await github_api_call(lambda: content_file.size, logger=self.logger, log_prefix=self.log_prefix)
if file_size > _WELCOME_EXTRA_INFO_MAX_BYTES:
self.logger.warning(
f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} is too large "
f"({file_size} bytes, max {_WELCOME_EXTRA_INFO_MAX_BYTES}). Skipping file, using config value."
)
return

raw_content = await github_api_call(
lambda: content_file.decoded_content, logger=self.logger, log_prefix=self.log_prefix
)
decoded = raw_content.decode("utf-8").strip()
self.welcome_extra_info = decoded
Comment thread
rnetser marked this conversation as resolved.
if decoded:
self.logger.info(
f"{self.log_prefix} Loaded welcome extra info from {_WELCOME_EXTRA_INFO_FILENAME} "
f"({len(decoded)} chars)"
)
else:
self.logger.info(
f"{self.log_prefix} Empty {_WELCOME_EXTRA_INFO_FILENAME} found, "
"suppressing config-based welcome extra info"
)
except UnknownObjectException:
self.logger.debug(f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} not found, using config value")
except UnicodeDecodeError:
self.logger.warning(f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} is not valid UTF-8, skipping file")
except Exception:
self.logger.exception(f"{self.log_prefix} Error loading {_WELCOME_EXTRA_INFO_FILENAME}, using config value")

async def get_pull_request(self, number: int | None = None) -> PullRequest | None:
if number:
self.logger.debug(f"{self.log_prefix} Attempting to get PR by number: {number}")
Expand Down
17 changes: 17 additions & 0 deletions webhook_server/libs/handlers/pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ def _prepare_welcome_comment(self) -> str:
### πŸ’‘ Tips

{self._prepare_tips_section}
{self._prepare_extra_info_welcome_section}\
Comment thread
rnetser marked this conversation as resolved.

For more information, please refer to the project documentation or contact the maintainers.
"""
Expand Down Expand Up @@ -691,6 +692,19 @@ def _prepare_tips_section(self) -> str:

return "\n".join(tips)

@property
def _prepare_extra_info_welcome_section(self) -> str:
"""Prepare the Additional Information section for the welcome comment.

Renders user-provided extra information from configuration or
.github-webhook-server-welcome-message.md file.
Content is injected as-is (markdown).
"""
if not self.github_webhook.welcome_extra_info:
return ""

return f"\n### πŸ“Œ Additional Information\n\n{self.github_webhook.welcome_extra_info}\n"

Comment thread
rnetser marked this conversation as resolved.
@property
def _prepare_ai_features_welcome_section(self) -> str:
"""Prepare the AI Features section for the welcome comment.
Expand Down Expand Up @@ -1920,6 +1934,7 @@ async def regenerate_welcome_message(self, pull_request: PullRequest) -> None:
If a welcome message exists, it will be updated.
If no welcome message exists, a new one will be created.
"""
await self.github_webhook.load_welcome_extra_info_from_file()
welcome_msg = self._prepare_welcome_comment()

def find_and_update_welcome_comment() -> bool:
Expand Down Expand Up @@ -1959,6 +1974,8 @@ async def process_new_or_reprocess_pull_request(self, pull_request: PullRequest)

# Add welcome message if it doesn't exist yet
if not await self._welcome_comment_exists(pull_request=pull_request):
# Load file-based welcome extra info only when building the welcome message
await self.github_webhook.load_welcome_extra_info_from_file()
self.logger.info(f"{self.log_prefix} Adding welcome message to PR")
welcome_msg = self._prepare_welcome_comment()
tasks.append(
Expand Down
65 changes: 65 additions & 0 deletions webhook_server/tests/test_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,3 +1006,68 @@ def test_ai_features_resolve_cherry_pick_conflicts_with_ai_repository_level(
}
finally:
shutil.rmtree(temp_dir)

def test_welcome_extra_info_global_valid(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test valid welcome-extra-info at global level."""
config = valid_minimal_config.copy()
config["welcome-extra-info"] = "Please review guidelines."

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["welcome-extra-info"] == "Please review guidelines."
finally:
shutil.rmtree(temp_dir)

def test_welcome_extra_info_schema_defines_string_type(self) -> None:
"""Test that schema defines welcome-extra-info as type string via $ref."""
schema_path = os.path.join(os.path.dirname(__file__), "..", "config", "schema.yaml")
with open(schema_path) as f:
schema = yaml.safe_load(f)

# Verify $defs definition
defs = schema["$defs"]
assert "welcome-extra-info" in defs
assert defs["welcome-extra-info"]["type"] == "string"

# Global level uses $ref
global_props = schema["properties"]
assert "welcome-extra-info" in global_props
assert global_props["welcome-extra-info"]["$ref"] == "#/$defs/welcome-extra-info"

# Per-repo level uses $ref
repo_props = schema["properties"]["repositories"]["additionalProperties"]["properties"]
assert "welcome-extra-info" in repo_props
assert repo_props["welcome-extra-info"]["$ref"] == "#/$defs/welcome-extra-info"

def test_welcome_extra_info_repository_level(
self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test valid welcome-extra-info at per-repo level."""
config = valid_minimal_config.copy()
config["repositories"]["test-repo"]["welcome-extra-info"] = "Repo-specific welcome info."

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["welcome-extra-info"] == "Repo-specific welcome info."
finally:
shutil.rmtree(temp_dir)

def test_welcome_extra_info_schema_defines_max_length(self) -> None:
"""Test that schema defines welcome-extra-info with maxLength 10240 in $defs."""
schema_path = os.path.join(os.path.dirname(__file__), "..", "config", "schema.yaml")
with open(schema_path) as f:
schema = yaml.safe_load(f)

# maxLength is in the $defs definition (both levels reference it via $ref)
assert schema["$defs"]["welcome-extra-info"]["maxLength"] == 10240
Loading