4848_GITHUB_TEAM_SYNC_LOCKS : dict [str , asyncio .Lock ] = {}
4949_GITHUB_TEAM_MEMBERS_CACHE : dict [tuple [str , str , str , tuple [str , ...]], tuple [float , dict [str , list [str ]]]] = {}
5050_GITHUB_TEAM_MEMBERS_LOCK = asyncio .Lock ()
51+ _GITHUB_APP_INSTALLATION_ID_CACHE : dict [tuple [str , str ], str ] = {}
52+ _GITHUB_APP_INSTALLATION_ID_LOCK = asyncio .Lock ()
5153_GITHUB_APP_INSTALLATION_TOKEN : dict [tuple [str , str ], tuple [str , float ]] = {}
5254_GITHUB_APP_INSTALLATION_TOKEN_LOCK = asyncio .Lock ()
5355
@@ -72,16 +74,32 @@ def _github_app_private_key(private_key: str = "", private_key_file: str = "") -
7274 return private_key .replace ("\\ n" , "\n " ).strip ()
7375
7476
77+ def _github_team_api_slug (team_key : str ) -> str :
78+ """Convert a configured group/team key to the GitHub team slug API form."""
79+ return team_key .strip ().lower ().replace (" " , "-" )
80+
81+
7582async def get_github_app_installation_token (
7683 app_id : str ,
77- installation_id : str ,
84+ installation_id : str = "" ,
85+ org_name : str = "" ,
7886 * ,
7987 private_key : str = "" ,
8088 private_key_file : str = "" ,
8189) -> str | None :
8290 """Create or reuse a GitHub App installation access token for group sync."""
91+ resolved_installation_id = await _resolve_github_app_installation_id (
92+ app_id ,
93+ installation_id ,
94+ org_name ,
95+ private_key = private_key ,
96+ private_key_file = private_key_file ,
97+ )
98+ if not resolved_installation_id :
99+ return None
100+
83101 now = time .time ()
84- cache_key = (app_id , installation_id )
102+ cache_key = (app_id , resolved_installation_id )
85103 cached_token = _GITHUB_APP_INSTALLATION_TOKEN .get (cache_key )
86104 if cached_token and now < cached_token [1 ] - 300 :
87105 return cached_token [0 ]
@@ -93,7 +111,7 @@ async def get_github_app_installation_token(
93111 return cached_token [0 ]
94112
95113 private_key = _github_app_private_key (private_key = private_key , private_key_file = private_key_file )
96- if not app_id or not installation_id or not private_key :
114+ if not app_id or not resolved_installation_id or not private_key :
97115 log .warning (
98116 "GitHub App installation token is unavailable because app id, installation id, or private key is missing"
99117 )
@@ -114,7 +132,7 @@ async def get_github_app_installation_token(
114132 async with (
115133 aiohttp .ClientSession () as session ,
116134 session .post (
117- f"https://api.github.com/app/installations/{ installation_id } /access_tokens" ,
135+ f"https://api.github.com/app/installations/{ resolved_installation_id } /access_tokens" ,
118136 headers = headers ,
119137 ) as resp ,
120138 ):
@@ -143,6 +161,86 @@ async def get_github_app_installation_token(
143161 return token
144162
145163
164+ async def _resolve_github_app_installation_id (
165+ app_id : str ,
166+ installation_id : str ,
167+ org_name : str ,
168+ * ,
169+ private_key : str = "" ,
170+ private_key_file : str = "" ,
171+ ) -> str | None :
172+ installation_id = installation_id .strip ()
173+ if installation_id :
174+ return installation_id
175+
176+ org_name = org_name .strip ()
177+ if not app_id or not org_name :
178+ log .warning ("GitHub App installation ID lookup is unavailable because app id or org name is missing" )
179+ return None
180+
181+ cache_key = (app_id , org_name )
182+ cached_installation_id = _GITHUB_APP_INSTALLATION_ID_CACHE .get (cache_key )
183+ if cached_installation_id :
184+ return cached_installation_id
185+
186+ async with _GITHUB_APP_INSTALLATION_ID_LOCK :
187+ cached_installation_id = _GITHUB_APP_INSTALLATION_ID_CACHE .get (cache_key )
188+ if cached_installation_id :
189+ return cached_installation_id
190+
191+ private_key = _github_app_private_key (private_key = private_key , private_key_file = private_key_file )
192+ if not private_key :
193+ log .warning ("GitHub App installation ID lookup is unavailable because the private key is missing" )
194+ return None
195+
196+ now = time .time ()
197+ app_jwt = jwt .encode (
198+ {"iat" : int (now ) - 60 , "exp" : int (now ) + 540 , "iss" : app_id },
199+ private_key ,
200+ algorithm = "RS256" ,
201+ )
202+ headers = {
203+ "Authorization" : f"Bearer { app_jwt } " ,
204+ "Accept" : "application/vnd.github+json" ,
205+ "X-GitHub-Api-Version" : "2022-11-28" ,
206+ }
207+
208+ try :
209+ async with (
210+ aiohttp .ClientSession () as session ,
211+ session .get (
212+ f"https://api.github.com/orgs/{ org_name } /installation" ,
213+ headers = headers ,
214+ ) as resp ,
215+ ):
216+ if resp .status == 404 :
217+ log .warning ("GitHub App installation lookup returned 404 for org %s" , org_name )
218+ return None
219+ if resp .status != 200 :
220+ log .warning (
221+ "GitHub App installation lookup returned status %d for org %s" ,
222+ resp .status ,
223+ org_name ,
224+ )
225+ return None
226+ data = await resp .json ()
227+ except Exception as e :
228+ log .warning ("Error resolving GitHub App installation for org %s: %s" , org_name , e )
229+ return None
230+
231+ resolved_installation_id = data .get ("id" )
232+ if resolved_installation_id is None :
233+ log .warning (
234+ "GitHub App installation lookup for org %s did not include an installation id" ,
235+ org_name ,
236+ )
237+ return None
238+
239+ resolved_installation_id = str (resolved_installation_id )
240+ _GITHUB_APP_INSTALLATION_ID_CACHE [cache_key ] = resolved_installation_id
241+ return resolved_installation_id
242+
243+
146244async def fetch_github_team_members (access_token : str , org_name : str , team_slug : str ) -> set [str ] | None :
147245 """Fetch all GitHub usernames in one team using the configured platform token."""
148246 if not access_token or not org_name or not team_slug :
@@ -160,9 +258,19 @@ async def fetch_github_team_members(access_token: str, org_name: str, team_slug:
160258 while True :
161259 url = f"https://api.github.com/orgs/{ org_name } /teams/{ team_slug } /members?per_page=100&page={ page } "
162260 async with session .get (url , headers = headers ) as resp :
261+ if resp .status == 404 :
262+ log .warning (
263+ "GitHub API returned status 404 when fetching members for team %s in org %s" ,
264+ team_slug ,
265+ org_name ,
266+ )
267+ return set ()
163268 if resp .status != 200 :
164269 log .warning (
165- "GitHub API returned status %d when fetching members for team %s" , resp .status , team_slug
270+ "GitHub API returned status %d when fetching members for team %s in org %s" ,
271+ resp .status ,
272+ team_slug ,
273+ org_name ,
166274 )
167275 return None
168276 data = await resp .json ()
@@ -214,19 +322,20 @@ async def fetch_github_team_members_table(
214322 installation_token = await get_github_app_installation_token (
215323 app_id ,
216324 installation_id ,
325+ org_name = org_name ,
217326 private_key = private_key ,
218327 private_key_file = private_key_file ,
219328 )
220329 if not installation_token :
221330 return None
222331
223332 teams_by_login : dict [str , list [str ]] = {}
224- for team_slug in github_team_keys :
225- members = await fetch_github_team_members (installation_token , org_name , team_slug )
333+ for team_key in github_team_keys :
334+ members = await fetch_github_team_members (installation_token , org_name , _github_team_api_slug ( team_key ) )
226335 if members is None :
227336 return None
228337 for login in members :
229- teams_by_login .setdefault (login , []).append (team_slug )
338+ teams_by_login .setdefault (login , []).append (team_key )
230339
231340 _GITHUB_TEAM_MEMBERS_CACHE [cache_key ] = (now , teams_by_login )
232341 return {login : list (teams ) for login , teams in teams_by_login .items ()}
0 commit comments