diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 5db4d3a9..61732c88 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -45,6 +45,7 @@ from webhook_server.utils.context import WebhookContext, get_context from webhook_server.utils.github_repository_settings import ( DEFAULT_BRANCH_PROTECTION, + get_github_app_slug, get_repository_github_app_api, ) from webhook_server.utils.github_retry import github_api_call @@ -542,28 +543,18 @@ async def process(self) -> Any: # Initialize app bot login for bot-PR identification (async) if not self.app_bot_login: - _github_app_api = await github_api_call( - get_repository_github_app_api, - config_=self.config, - repository_name=self.repository_full_name, - logger=self.logger, - log_prefix=self.log_prefix, - ) - if _github_app_api: - try: - self.app_bot_login = await github_api_call( - lambda: _github_app_api.get_user().login, - logger=self.logger, - log_prefix=self.log_prefix, - ) - except asyncio.CancelledError: - raise - except Exception: - self.logger.exception( - f"{self.log_prefix} Failed to get app bot login — bot-PR detection may not work" - ) - else: - self.logger.debug(f"{self.log_prefix} No GitHub App API available — app_bot_login not set") + try: + _app_slug = await github_api_call( + get_github_app_slug, + self.config, + logger=self.logger, + log_prefix=self.log_prefix, + ) + self.app_bot_login = f"{_app_slug}[bot]" + except asyncio.CancelledError: + raise + except Exception: + self.logger.exception(f"{self.log_prefix} Failed to get app bot login — bot-PR detection may not work") event_log: str = f"Event type: {self.github_event}. event ID: {self.x_github_delivery}" diff --git a/webhook_server/utils/github_repository_settings.py b/webhook_server/utils/github_repository_settings.py index 5628c9fe..a66ccb65 100644 --- a/webhook_server/utils/github_repository_settings.py +++ b/webhook_server/utils/github_repository_settings.py @@ -1,5 +1,6 @@ import copy import os +import threading from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor, as_completed from copy import deepcopy @@ -43,6 +44,8 @@ } LOGGER = get_logger_with_params() +_github_app_slug_cache: str | None = None +_github_app_slug_lock = threading.Lock() def _get_github_repo_api(github_api: github.Github, repository: int | str) -> Repository | None: @@ -407,15 +410,23 @@ def _set_checkrun_queued(_api: Repository, _pull_request: PullRequest) -> None: return True, f"[API user {api_user}] - {repository}: Set check run status to {QUEUED_STR} is done", LOGGER.debug -def get_repository_github_app_api(config_: Config, repository_name: str) -> Github | None: - LOGGER.debug("Getting repositories GitHub app API") +def _create_github_integration(config_: Config) -> GithubIntegration: + """Create a GithubIntegration instance with App JWT authentication. + Reads the private key and app ID from config to create an authenticated + GithubIntegration. This is the shared setup for all GitHub App API operations. + """ with open(os.path.join(config_.data_dir, "webhook-server.private-key.pem")) as fd: private_key = fd.read() github_app_id: int = config_.root_data["github-app-id"] auth: AppAuth = Auth.AppAuth(app_id=github_app_id, private_key=private_key) - app_instance: GithubIntegration = GithubIntegration(auth=auth) + return GithubIntegration(auth=auth) + + +def get_repository_github_app_api(config_: Config, repository_name: str) -> Github | None: + LOGGER.debug("Getting repositories GitHub app API") + app_instance = _create_github_integration(config_) owner: str repo: str owner, repo = repository_name.split("/") @@ -432,19 +443,39 @@ def get_repository_github_app_api(config_: Config, repository_name: str) -> Gith return None +def get_github_app_slug(config_: Config) -> str: + """Get the GitHub App slug using App JWT authentication. + + Returns the app slug (e.g., 'manage-repositories-app'). + Caches the result at module level since the slug is immutable. + Raises on failure so github_api_call() can apply retry/backoff. + """ + global _github_app_slug_cache + + if _github_app_slug_cache is not None: + return _github_app_slug_cache + + with _github_app_slug_lock: + # Double-checked locking + if _github_app_slug_cache is not None: + return _github_app_slug_cache + + LOGGER.debug("Getting GitHub App slug") + app_instance = _create_github_integration(config_) + slug = app_instance.get_app().slug + if not slug: + raise ValueError("GitHub App returned empty slug") + _github_app_slug_cache = slug + return _github_app_slug_cache + + def get_repository_github_app_token(config_: Config, repository_name: str) -> str | None: """Get a raw GitHub App installation token string for use with CLI tools. Returns the token string or None if the app is not configured/installed. """ LOGGER.debug(f"Getting GitHub App installation token for {repository_name}") - - with open(os.path.join(config_.data_dir, "webhook-server.private-key.pem")) as fd: - private_key = fd.read() - - github_app_id: int = config_.root_data["github-app-id"] - auth: AppAuth = Auth.AppAuth(app_id=github_app_id, private_key=private_key) - app_instance: GithubIntegration = GithubIntegration(auth=auth) + app_instance = _create_github_integration(config_) owner, repo = repository_name.split("/", maxsplit=1) try: