"""GitHub token lookup utilities.""" from __future__ import annotations import logging from collections.abc import Mapping from datetime import UTC, datetime from typing import Any from langgraph.config import get_config from langgraph_sdk import get_client from langgraph_sdk.errors import NotFoundError from ..encryption import decrypt_token logger = logging.getLogger(__name__) _GITHUB_TOKEN_METADATA_KEY = "github_token_encrypted" _GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY = "github_token_expires_at" class GitHubAuthError(Exception): """Raised when a GitHub call returns 401, signalling a stale/revoked token.""" # Treat tokens with <= this many seconds remaining as expired so we re-auth # before kicking off long agent runs. _GITHUB_TOKEN_EXPIRY_SKEW_SECONDS = 60 client = get_client() def _read_encrypted_github_token(metadata: dict[str, Any]) -> str | None: encrypted_token = metadata.get(_GITHUB_TOKEN_METADATA_KEY) return encrypted_token if isinstance(encrypted_token, str) and encrypted_token else None def _decrypt_github_token(encrypted_token: str | None) -> str | None: if not encrypted_token: return None return decrypt_token(encrypted_token) def _is_expired(expires_at: Any, *, now: datetime | None = None) -> bool: """Return True when ``expires_at`` is past (or close to) ``now``. Accepts ISO-8601 strings (with or without trailing Z) and unix timestamps. Unparseable values are treated as not expired so we don't break callers that haven't started persisting an expiry yet. """ if expires_at is None: return False parsed: datetime | None = None if isinstance(expires_at, int | float): try: parsed = datetime.fromtimestamp(float(expires_at), tz=UTC) except (OverflowError, OSError, ValueError): return False elif isinstance(expires_at, str): raw = expires_at.strip() if not raw: return False if raw.endswith("Z"): raw = raw[:-1] + "+00:00" try: parsed = datetime.fromisoformat(raw) except ValueError: return False if parsed is None: return False if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC) current = (now or datetime.now(UTC)).astimezone(UTC) return (parsed - current).total_seconds() <= _GITHUB_TOKEN_EXPIRY_SKEW_SECONDS def _read_token_if_fresh(metadata: dict[str, Any]) -> str | None: """Decrypt the cached token only if it has not expired.""" encrypted = _read_encrypted_github_token(metadata) if not encrypted: return None if _is_expired(metadata.get(_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY)): return None return _decrypt_github_token(encrypted) def get_github_token(run_config: Mapping[str, Any] | None = None) -> str | None: """Resolve a GitHub token from run metadata. Pass ``run_config`` when LangGraph runnable config is already available (e.g. after ``get_config()`` in callers). Omit to read from ``get_config()`` (required runnable context). Returns ``None`` for tokens whose ``github_token_expires_at`` is past. """ resolved = run_config if run_config is not None else get_config() return _read_token_if_fresh(resolved.get("metadata", {})) async def get_github_token_from_thread( thread_id: str, ) -> tuple[str | None, str | None, str | None]: """Resolve a GitHub token from LangGraph thread metadata. Returns ``(None, None, None)`` when no token is cached or when the cached token's ``github_token_expires_at`` has elapsed — callers must treat the cache as missing in that case and re-resolve. On a fresh hit, returns the decrypted token, its ciphertext, and the persisted expiry (or ``None``). """ try: thread = await client.threads.get(thread_id) except NotFoundError: logger.debug("Thread %s not found while looking up GitHub token", thread_id) return None, None, None except Exception: # noqa: BLE001 logger.exception("Failed to fetch thread metadata for %s", thread_id) return None, None, None metadata = (thread or {}).get("metadata", {}) encrypted_token = _read_encrypted_github_token(metadata) if not encrypted_token: return None, None, None expires_at_raw = metadata.get(_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY) if _is_expired(expires_at_raw): logger.info("Cached GitHub token for thread %s has expired; re-resolving", thread_id) return None, None, None token = _decrypt_github_token(encrypted_token) if token: logger.info("Found GitHub token in thread metadata for thread %s", thread_id) expires_at = expires_at_raw if isinstance(expires_at_raw, str) else None return token, encrypted_token, expires_at async def invalidate_cached_github_token(thread_id: str) -> None: """Clear a cached GitHub token from thread metadata. Called when a downstream GitHub API call returns 401, so the next run re-resolves a fresh token instead of replaying the revoked ciphertext. """ try: await client.threads.update( thread_id=thread_id, metadata={ _GITHUB_TOKEN_METADATA_KEY: None, _GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY: None, }, ) logger.info("Invalidated cached GitHub token for thread %s", thread_id) except Exception: logger.exception("Failed to invalidate cached GitHub token for thread %s", thread_id)