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
129 changes: 106 additions & 23 deletions src/chat_sdk/adapters/github/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None:
self._installation_id: int | None = None
self._installation_token_cache: dict[int, tuple[str, float]] = {}
self._token_lock = asyncio.Lock()
# Serializes concurrent bot-user-ID detection so concurrent webhooks
# don't all race to fetch the (identical) bot identity (see
# ``_detect_bot_user_id``).
self._detect_lock = asyncio.Lock()

# Shared aiohttp session for connection pooling
self._http_session: Any | None = None
Expand Down Expand Up @@ -201,21 +205,77 @@ async def initialize(self, chat: ChatInstance) -> None:
"""Initialize the adapter."""
self._chat = chat

if not self._bot_user_id and self._auth_token:
# Fetch bot user ID if not provided. For multi-tenant mode there is no
# fixed installation yet, so detection happens lazily on the first
# webhook (see ``handle_webhook``) so ``is_me`` checks work for the
# very first reply.
if self._bot_user_id is None and self._auth_token:
await self._detect_bot_user_id()

self._logger.info("GitHub adapter initialized")

async def _detect_bot_user_id(self, installation_id: int | None = None) -> None:
"""Fetch the bot's user ID from GitHub. Used for self-message detection.

Best-effort: errors are swallowed so they do not block webhook
processing. The bot identity is the same across all installations of
the same App, so we only detect once and cache the result on
``self._bot_user_id``.

Mirrors the upstream TypeScript ``detectBotUserId`` sequence:
try ``users.getAuthenticated`` (``GET /user``) first — this works for
PAT mode and (returns the bot user) for installation tokens too. On
failure, fall back to ``apps.getAuthenticated`` (``GET /app``) and
resolve the bot user via ``users.getByUsername`` (``GET /users/{slug}[bot]``).
"""
# Cache hit: already detected once, don't re-fetch per webhook. This is
# the fast path that avoids taking the lock once detection has succeeded.
if self._bot_user_id is not None:
return

# Double-checked locking: concurrent webhooks must not all race to fetch
# the (identical) bot identity. Serialize detection and re-check the
# cache inside the lock so only the first caller hits the API.
async with self._detect_lock:
if self._bot_user_id is not None:
return

try:
user_data = await self._github_api_request("GET", "/user")
self._bot_user_id = user_data.get("id")
user = await self._github_api_request("GET", "/user", installation_id=installation_id)
self._bot_user_id = user.get("id")
self._logger.info(
"GitHub auth completed",
"GitHub bot user ID auto-detected",
{
"botUserId": self._bot_user_id,
"login": user_data.get("login"),
"login": user.get("login"),
},
)
return
except Exception as error:
self._logger.warn("Could not fetch bot user ID", {"error": str(error)})
self._logger.debug(
"users.getAuthenticated failed; falling back to apps.getAuthenticated",
{"error": str(error)},
)

self._logger.info("GitHub adapter initialized")
try:
# For App-authenticated installation tokens, /user is not
# available; use apps.getAuthenticated (GET /app, authenticated
# with the App JWT) and resolve the bot user via
# /users/{login}[bot].
app = await self._github_api_request("GET", "/app", installation_id=installation_id)
if app:
login = f"{app['slug']}[bot]"
bot_user = await self._github_api_request("GET", f"/users/{login}", installation_id=installation_id)
self._bot_user_id = bot_user.get("id")
self._logger.info(
"GitHub bot user ID auto-detected via app slug",
{
"botUserId": self._bot_user_id,
"login": login,
},
)
except Exception as error:
self._logger.warn("Could not auto-detect GitHub bot user ID", {"error": str(error)})

async def get_user(self, user_id: str) -> UserInfo | None:
"""Look up a GitHub user by numeric account ID.
Expand Down Expand Up @@ -306,6 +366,13 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
if owner_login and repo_name:
await self._store_installation_id(owner_login, repo_name, installation_id)

# Eagerly resolve the bot user ID before dispatching to handlers, so
# is_me checks work on the very first webhook. Without this,
# multi-tenant adapters can self-reply-loop until detection fires
# lazily elsewhere. Cached after the first detection (once per process).
if self._bot_user_id is None:
await self._detect_bot_user_id(installation_id)

if event_type == "issue_comment":
if payload.get("action") == "created":
self._handle_issue_comment(payload, installation_id, options)
Expand Down Expand Up @@ -629,12 +696,10 @@ async def add_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue

async def remove_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue | str) -> None:
"""Remove a reaction from a message."""
if not self._bot_user_id and self._auth_token:
try:
user_data = await self._github_api_request("GET", "/user")
self._bot_user_id = user_data.get("id")
except Exception:
self._logger.warn("Could not detect bot user ID for reaction removal")
# Multi-tenant mode has no fixed installation, so initialize() can't
# detect _bot_user_id. Detect lazily (cached after first success).
if self._bot_user_id is None:
await self._detect_bot_user_id()

decoded = self.decode_thread_id(thread_id)
comment_id = int(message_id)
Expand Down Expand Up @@ -1059,24 +1124,42 @@ async def _get_installation_token(self, installation_id: int) -> str:
)
return token

async def _github_api_request(self, method: str, path: str, body: Any = None) -> Any:
async def _github_api_request(
self, method: str, path: str, body: Any = None, *, installation_id: int | None = None
) -> Any:
"""Make a request to the GitHub API.

Supports PAT auth (``_auth_token``) and GitHub App auth
(``_app_credentials`` with JWT -> installation token exchange).

``installation_id`` overrides ``self._installation_id`` for App auth.
Multi-tenant mode has no fixed installation on the adapter (it is
extracted per-webhook), so callers operating inside a webhook context
pass the webhook's installation explicitly.
"""
auth_token = self._auth_token

# GitHub App auth: exchange JWT for installation token
# GitHub App auth.
if not auth_token and self._app_credentials:
installation_id = self._installation_id
if not installation_id:
raise RuntimeError(
"Installation ID required for GitHub App authentication. "
"This usually means you're trying to make an API call outside of a webhook context. "
"For proactive messages, use thread IDs from previous webhook interactions."
)
auth_token = await self._get_installation_token(installation_id)
if path == "/app":
# App-level endpoints (apps.getAuthenticated) REQUIRE app-JWT
# auth; installation tokens get 401/403 here. Authenticate with
# the App JWT directly instead of exchanging for an installation
# token. This is what makes bot-user-ID detection work in the
# GitHub-App/installation case (the /user path 403s on
# installation tokens, so this fallback must succeed).
auth_token = self._generate_app_jwt()
else:
# Installation-scoped endpoints: exchange the JWT for an
# installation token.
resolved_installation_id = installation_id if installation_id is not None else self._installation_id
if not resolved_installation_id:
raise RuntimeError(
"Installation ID required for GitHub App authentication. "
"This usually means you're trying to make an API call outside of a webhook context. "
"For proactive messages, use thread IDs from previous webhook interactions."
)
auth_token = await self._get_installation_token(resolved_installation_id)

headers: dict[str, str] = {
"Accept": "application/vnd.github+json",
Expand Down
Loading
Loading