44import time
55from collections .abc import Awaitable , Callable
66from dataclasses import dataclass
7- from datetime import datetime
7+ from datetime import UTC , datetime , timedelta
88from typing import Any , Literal , TypeVar
99
10- from githubkit import AppAuthStrategy , AppInstallationAuthStrategy , GitHub
10+ from githubkit import GitHub , TokenAuthStrategy
11+ from githubkit .auth import BaseAuthStrategy
1112from githubkit .exception import GraphQLFailed , RequestFailed
1213
1314from druks .core .apis .exceptions import GitHubAppNotInstalledError
1415from druks .core .utils .time import ensure_utc
16+ from druks .redis import get_client
17+ from druks .services .constants import OAUTH_TOKEN_TTL_SKEW_SECONDS
1518
1619logger = logging .getLogger (__name__ )
1720
@@ -63,22 +66,20 @@ async def wrapper(self: "GitHubClient", repo: str, *args: Any, **kwargs: Any) ->
6366
6467
6568class GitHubClient :
69+ """``app_auth`` signs the App routes at ``app_url``. Repository calls go to GitHub
70+ with the installation tokens those routes mint."""
71+
6672 def __init__ (
6773 self ,
6874 * ,
6975 app_id : str ,
70- private_key : str ,
71- base_url : str = GITHUB_API_URL ,
76+ app_auth : BaseAuthStrategy ,
77+ app_url : str = GITHUB_API_URL ,
7278 slug : str = "" ,
7379 ) -> None :
7480 self ._app_id = app_id
75- self ._private_key = private_key
76- self ._base_url = base_url
7781 self ._slug = slug
78- self ._app = GitHub (
79- AppAuthStrategy (app_id , private_key ),
80- base_url = base_url ,
81- )
82+ self ._app = GitHub (app_auth , base_url = app_url )
8283 self ._installation_cache : dict [str , int ] = {}
8384 self ._repo_gh_cache : dict [int , GitHub ] = {}
8485
@@ -116,10 +117,8 @@ async def list_repos_for_owner(self, owner: str) -> list[dict[str, Any]]:
116117 except GitHubAppNotInstalledError :
117118 return []
118119
119- async with GitHub (
120- AppInstallationAuthStrategy (self ._app_id , self ._private_key , installation_id ),
121- base_url = self ._base_url ,
122- ) as github :
120+ github = await self ._get_for_installation (installation_id )
121+ async with github :
123122 repos : list [dict [str , Any ]] = []
124123 page = 1
125124 while True :
@@ -149,10 +148,8 @@ async def create_repo_from_template(self, owner: str, template_repo: str, name:
149148 installation_id = await self ._owner_installation_id (owner )
150149 template_owner , template_name = template_repo .split ("/" , 1 )
151150 full_name = f"{ owner } /{ name } "
152- async with GitHub (
153- AppInstallationAuthStrategy (self ._app_id , self ._private_key , installation_id ),
154- base_url = self ._base_url ,
155- ) as github :
151+ github = await self ._get_for_installation (installation_id )
152+ async with github :
156153 try :
157154 await github .rest .repos .async_create_using_template (
158155 template_owner ,
@@ -168,15 +165,16 @@ async def create_repo_from_template(self, owner: str, template_repo: str, name:
168165 raise
169166 return full_name
170167
171- async def list_installation_accounts (self ) -> tuple [str , ...]:
168+ async def list_installation_accounts (self , cached : bool = True ) -> tuple [str , ...]:
172169 """Account logins (orgs/users) this App is installed on — the
173170 authoritative "where druks may act". Install the App somewhere and
174171 druks works there; uninstall and it stops. Cached ~10 min per app
175- id, serving the last-known set when GitHub hiccups."""
176- cached = _INSTALLATION_ACCOUNTS_CACHE .get (self ._app_id )
172+ id, serving the last-known set when GitHub hiccups. ``cached=False``
173+ asks now, after an install."""
174+ known = _INSTALLATION_ACCOUNTS_CACHE .get (self ._app_id )
177175 now = time .monotonic ()
178- if cached and now - cached [0 ] < _INSTALLATION_ACCOUNTS_TTL_SECONDS :
179- return cached [1 ]
176+ if cached and known and now - known [0 ] < _INSTALLATION_ACCOUNTS_TTL_SECONDS :
177+ return known [1 ]
180178 try :
181179 accounts : list [str ] = []
182180 page = 1
@@ -194,12 +192,12 @@ async def list_installation_accounts(self) -> tuple[str, ...]:
194192 break
195193 page += 1
196194 except Exception :
197- if cached :
195+ if known :
198196 logger .warning (
199197 "Could not refresh App installations; serving the last-known set." ,
200198 exc_info = True ,
201199 )
202- return cached [1 ]
200+ return known [1 ]
203201 raise
204202 result = tuple (dict .fromkeys (accounts ))
205203 _INSTALLATION_ACCOUNTS_CACHE [self ._app_id ] = (now , result )
@@ -228,7 +226,7 @@ async def get_bot_git_author(self) -> tuple[str, str]:
228226 bot_name = f"{ await self .get_mention_handle ()} [bot]"
229227 user_id = _BOT_USER_ID_CACHE .get (self ._app_id )
230228 if not user_id :
231- async with GitHub (base_url = self . _base_url ) as github :
229+ async with GitHub (base_url = GITHUB_API_URL ) as github :
232230 response = await github .rest .users .async_get_by_username (bot_name )
233231 user_id = response .parsed_data .id
234232 _BOT_USER_ID_CACHE [self ._app_id ] = user_id
@@ -238,12 +236,8 @@ async def get_authenticated_app_slug(self) -> str:
238236 """The slug GitHub reports for these credentials — a live App-JWT call,
239237 so it proves the App ID matches the PEM. The connect flow's validation;
240238 mention resolution reads the stored slug instead."""
241- async with GitHub (
242- AppAuthStrategy (self ._app_id , self ._private_key ),
243- base_url = self ._base_url ,
244- ) as github :
245- response = await github .rest .apps .async_get_authenticated ()
246- return str (getattr (response .parsed_data , "slug" , None ) or "" )
239+ response = await self ._app .rest .apps .async_get_authenticated ()
240+ return str (getattr (response .parsed_data , "slug" , None ) or "" )
247241
248242 async def _installation_id (self , repo : str ) -> int :
249243 if repo in self ._installation_cache :
@@ -259,22 +253,38 @@ async def _installation_id(self, repo: str) -> int:
259253 self ._installation_cache [repo ] = installation_id
260254 return installation_id
261255
256+ async def _get_installation_token (self , installation_id : int ) -> tuple [str , datetime ]:
257+ """The installation's token and its expiry, kept in Redis until the skew before it
258+ expires."""
259+ redis = get_client ()
260+ key = f"github:installation_token:{ installation_id } "
261+ if token := await redis .get (key ):
262+ return token .decode (), datetime .now (UTC ) + timedelta (seconds = await redis .ttl (key ))
263+ response = await self ._app .rest .apps .async_create_installation_access_token (
264+ installation_id ,
265+ )
266+ minted = response .parsed_data
267+ expires_at = ensure_utc (datetime .fromisoformat (str (minted .expires_at )))
268+ ttl = int ((expires_at - datetime .now (UTC )).total_seconds ()) - OAUTH_TOKEN_TTL_SKEW_SECONDS
269+ if ttl > 0 :
270+ await redis .set (key , str (minted .token ), ex = ttl )
271+ return str (minted .token ), expires_at
272+
273+ async def _get_for_installation (self , installation_id : int ) -> GitHub :
274+ token , _ = await self ._get_installation_token (installation_id )
275+ return GitHub (TokenAuthStrategy (token ), base_url = GITHUB_API_URL )
276+
262277 async def _for_repo (self , repo : str ) -> GitHub :
263278 installation_id = await self ._installation_id (repo )
264279 if installation_id not in self ._repo_gh_cache :
265- self ._repo_gh_cache [installation_id ] = GitHub (
266- AppInstallationAuthStrategy (
267- self ._app_id ,
268- self ._private_key ,
269- installation_id ,
270- ),
271- base_url = self ._base_url ,
272- )
280+ self ._repo_gh_cache [installation_id ] = await self ._get_for_installation (installation_id )
273281 return self ._repo_gh_cache [installation_id ]
274282
275283 async def _invalidate_for_repo (self , repo : str ) -> None :
276284 installation_id = self ._installation_cache .pop (repo , None )
277285 github = self ._repo_gh_cache .pop (installation_id , None ) if installation_id else None
286+ if installation_id :
287+ await get_client ().delete (f"github:installation_token:{ installation_id } " )
278288 if github :
279289 try :
280290 await github .__aexit__ (None , None , None )
@@ -288,22 +298,9 @@ async def _invalidate_for_repo(self, repo: str) -> None:
288298 @_retry_on_401
289299 async def token_for_repo (self , repo : str ) -> tuple [str , datetime ]:
290300 """The installation token for ``repo`` and the expiry GitHub gave it."""
291- # The decorator drops the cached installation client + id on a
292- # 401 and retries once. Important here because git is the
293- # consumer of the minted token — once it's handed to git,
294- # there's no httpx-layer retry hook to recover from a stale
295- # ``installation_id`` (e.g. after the App was reinstalled and
296- # got a new id, leaving every worker's cache pointing at the
297- # dead one). The 401 from this method's own SDK call gives us
298- # the only chance to invalidate before git presents a bad
299- # token to GitHub and produces ``expected flush after ref
300- # listing``.
301- installation_id = await self ._installation_id (repo )
302- token_response = await self ._app .rest .apps .async_create_installation_access_token (
303- installation_id ,
304- )
305- token = token_response .parsed_data
306- return str (token .token ), ensure_utc (datetime .fromisoformat (str (token .expires_at )))
301+ # git holds the token after this call, so a 401 here is the one chance to drop
302+ # a stale installation id (the App was reinstalled) and mint again.
303+ return await self ._get_installation_token (await self ._installation_id (repo ))
307304
308305 @_retry_on_401
309306 async def get_repository (self , repo : str ) -> dict [str , Any ]:
0 commit comments