From ba74574d80ab952f5dc903719b04358aad4f58f7 Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 18:29:19 +0300 Subject: [PATCH 1/7] fix: use get_app().slug for app_bot_login instead of get_user().login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_user() always returns 403 for GitHub App installation tokens — it's a platform limitation, not a permissions issue. get_app() is the correct endpoint for app tokens and returns app metadata including slug. Bot login format is always {slug}[bot]. Closes #1113 Signed-off-by: rnetser Generated-by: Claude --- webhook_server/libs/github_api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 5db4d3a9..202d09bd 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -551,11 +551,12 @@ async def process(self) -> Any: ) if _github_app_api: try: - self.app_bot_login = await github_api_call( - lambda: _github_app_api.get_user().login, + _app = await github_api_call( + _github_app_api.get_app, logger=self.logger, log_prefix=self.log_prefix, ) + self.app_bot_login = f"{_app.slug}[bot]" except asyncio.CancelledError: raise except Exception: From a5fb576e9d0ac7ec2e32b057938d27d7807bb69d Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 18:56:26 +0300 Subject: [PATCH 2/7] fix: wrap slug access inside github_api_call for retry protection Moves .slug property access inside the github_api_call lambda to keep it off the event loop and get retry protection, per AGENTS.md guidelines. Signed-off-by: rnetser Assisted-by: Claude --- webhook_server/libs/github_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 202d09bd..6a3dbb4d 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -551,12 +551,11 @@ async def process(self) -> Any: ) if _github_app_api: try: - _app = await github_api_call( - _github_app_api.get_app, + self.app_bot_login = await github_api_call( + lambda: f"{_github_app_api.get_app().slug}[bot]", logger=self.logger, log_prefix=self.log_prefix, ) - self.app_bot_login = f"{_app.slug}[bot]" except asyncio.CancelledError: raise except Exception: From a20318207b3e7797f566171494a1db9be1253ea1 Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 19:19:20 +0300 Subject: [PATCH 3/7] fix: use AppAuth JWT for get_app() instead of installation token get_app() requires AppAuth (JWT), not AppInstallationAuth. Added get_github_app_slug() helper that creates its own GithubIntegration with proper AppAuth to call get_app().slug. Removed the intermediate _github_app_api variable since we no longer need the installation token for bot login detection. Signed-off-by: rnetser Assisted-by: Claude --- webhook_server/libs/github_api.py | 40 +++++++++---------- .../utils/github_repository_settings.py | 19 +++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 6a3dbb4d..92e7ea1a 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,25 @@ 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: f"{_github_app_api.get_app().slug}[bot]", - 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" + try: + _app_slug = await github_api_call( + get_github_app_slug, + self.config, + logger=self.logger, + log_prefix=self.log_prefix, + ) + if _app_slug: + self.app_bot_login = f"{_app_slug}[bot]" + else: + self.logger.error( + f"{self.log_prefix} Failed to get app slug — bot-PR detection may not work" ) - else: - self.logger.debug(f"{self.log_prefix} No GitHub App API available — app_bot_login not set") + 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..45b5b745 100644 --- a/webhook_server/utils/github_repository_settings.py +++ b/webhook_server/utils/github_repository_settings.py @@ -432,6 +432,25 @@ def get_repository_github_app_api(config_: Config, repository_name: str) -> Gith return None +def get_github_app_slug(config_: Config) -> str | None: + """Get the GitHub App slug using App JWT authentication. + + Returns the app slug (e.g., 'manage-repositories-app') or None if unavailable. + """ + try: + 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 app_instance.get_app().slug + + except Exception: + LOGGER.exception("Failed to get GitHub App slug") + return None + + 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. From 0a38fb11e6439733662342f33f0ec5de67c2b5f5 Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 19:25:11 +0300 Subject: [PATCH 4/7] fix: let exceptions propagate from get_github_app_slug for retry Removed try/except from get_github_app_slug() so exceptions propagate through github_api_call() for retry/backoff on transient failures. The outer try/except in process() handles permanent failures. Signed-off-by: rnetser Assisted-by: Claude --- webhook_server/libs/github_api.py | 7 +----- .../utils/github_repository_settings.py | 22 ++++++++----------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 92e7ea1a..ade9ed3e 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -550,12 +550,7 @@ async def process(self) -> Any: logger=self.logger, log_prefix=self.log_prefix, ) - if _app_slug: - self.app_bot_login = f"{_app_slug}[bot]" - else: - self.logger.error( - f"{self.log_prefix} Failed to get app slug — bot-PR detection may not work" - ) + self.app_bot_login = f"{_app_slug}[bot]" except asyncio.CancelledError: raise except Exception: diff --git a/webhook_server/utils/github_repository_settings.py b/webhook_server/utils/github_repository_settings.py index 45b5b745..5c38f6a7 100644 --- a/webhook_server/utils/github_repository_settings.py +++ b/webhook_server/utils/github_repository_settings.py @@ -432,23 +432,19 @@ def get_repository_github_app_api(config_: Config, repository_name: str) -> Gith return None -def get_github_app_slug(config_: Config) -> str | 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') or None if unavailable. + Returns the app slug (e.g., 'manage-repositories-app'). + Raises on failure so github_api_call() can apply retry/backoff. """ - try: - 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 app_instance.get_app().slug + with open(os.path.join(config_.data_dir, "webhook-server.private-key.pem")) as fd: + private_key = fd.read() - except Exception: - LOGGER.exception("Failed to get GitHub App slug") - return None + 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 app_instance.get_app().slug def get_repository_github_app_token(config_: Config, repository_name: str) -> str | None: From 6e2bd74309bfdb46f981618a6039ad5402504396 Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 19:36:52 +0300 Subject: [PATCH 5/7] style: fix ruff formatting Signed-off-by: rnetser Assisted-by: Claude --- webhook_server/libs/github_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index ade9ed3e..61732c88 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -554,9 +554,7 @@ async def process(self) -> Any: 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" - ) + 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}" From 170ddd7635cc912000fa2ef9571f7c32c225f2d6 Mon Sep 17 00:00:00 2001 From: rnetser Date: Sun, 14 Jun 2026 19:44:47 +0300 Subject: [PATCH 6/7] perf: cache GitHub App slug at module level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slug is immutable — no need to read PEM + call GET /app on every webhook event. First call fetches and caches, subsequent calls return cached value. Assisted-by: Claude Signed-off-by: rnetser --- webhook_server/utils/github_repository_settings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/webhook_server/utils/github_repository_settings.py b/webhook_server/utils/github_repository_settings.py index 5c38f6a7..041de31d 100644 --- a/webhook_server/utils/github_repository_settings.py +++ b/webhook_server/utils/github_repository_settings.py @@ -43,6 +43,7 @@ } LOGGER = get_logger_with_params() +_github_app_slug_cache: str | None = None def _get_github_repo_api(github_api: github.Github, repository: int | str) -> Repository | None: @@ -436,15 +437,22 @@ 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 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 app_instance.get_app().slug + _github_app_slug_cache = app_instance.get_app().slug + return _github_app_slug_cache def get_repository_github_app_token(config_: Config, repository_name: str) -> str | None: From 35b6257de9aeb2fa9efe122dd5a12cde0cc59f4d Mon Sep 17 00:00:00 2001 From: rnetser Date: Mon, 15 Jun 2026 18:18:49 +0300 Subject: [PATCH 7/7] refactor: extract _create_github_integration helper and harden slug cache Addresses human review findings: (1) extracted shared _create_github_integration() helper to eliminate DRY violation across 3 functions, (2) added threading.Lock with double-checked locking for slug cache, (3) added empty slug fail-fast guard, (4) added debug logging. Signed-off-by: rnetser Assisted-by: Claude --- .../utils/github_repository_settings.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/webhook_server/utils/github_repository_settings.py b/webhook_server/utils/github_repository_settings.py index 041de31d..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 @@ -44,6 +45,7 @@ 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: @@ -408,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("/") @@ -445,14 +455,18 @@ def get_github_app_slug(config_: Config) -> str: if _github_app_slug_cache is not None: return _github_app_slug_cache - 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) - _github_app_slug_cache = app_instance.get_app().slug - 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: @@ -461,13 +475,7 @@ def get_repository_github_app_token(config_: Config, repository_name: str) -> st 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: