Skip to content

Commit 7638bdb

Browse files
committed
perf(hub): batch GitHub team sync with GraphQL
1 parent 4821e4d commit 7638bdb

2 files changed

Lines changed: 259 additions & 8 deletions

File tree

runtime/hub/core/groups.py

Lines changed: 195 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,194 @@ async def fetch_github_team_members(access_token: str, org_name: str, team_slug:
290290
return members
291291

292292

293+
def _build_github_team_members_graphql_query(team_count: int) -> str:
294+
variable_defs = ["$org: String!"]
295+
team_fields = []
296+
for index in range(team_count):
297+
variable_defs.extend([f"$slug{index}: String!", f"$after{index}: String"])
298+
team_fields.append(
299+
f"""
300+
team{index}: team(slug: $slug{index}) {{
301+
members(first: 100, after: $after{index}) {{
302+
nodes {{
303+
login
304+
}}
305+
pageInfo {{
306+
hasNextPage
307+
endCursor
308+
}}
309+
}}
310+
}}"""
311+
)
312+
313+
return f"""
314+
query({", ".join(variable_defs)}) {{
315+
organization(login: $org) {{
316+
{"".join(team_fields)}
317+
}}
318+
}}
319+
"""
320+
321+
322+
_GITHUB_ORG_TEAMS_GRAPHQL_QUERY = """
323+
query($org: String!, $after: String) {
324+
organization(login: $org) {
325+
teams(first: 100, after: $after) {
326+
nodes {
327+
name
328+
slug
329+
}
330+
pageInfo {
331+
hasNextPage
332+
endCursor
333+
}
334+
}
335+
}
336+
}
337+
"""
338+
339+
340+
async def fetch_github_org_team_slugs_graphql(access_token: str, org_name: str) -> set[str] | None:
341+
"""Fetch all team slugs that actually exist in a GitHub organization."""
342+
if not access_token or not org_name:
343+
return set()
344+
345+
headers = {
346+
"Authorization": f"Bearer {access_token}",
347+
"Accept": "application/vnd.github+json",
348+
"X-GitHub-Api-Version": "2022-11-28",
349+
}
350+
team_slugs: set[str] = set()
351+
after: str | None = None
352+
353+
try:
354+
async with aiohttp.ClientSession() as session:
355+
while True:
356+
async with session.post(
357+
"https://api.github.com/graphql",
358+
headers=headers,
359+
json={"query": _GITHUB_ORG_TEAMS_GRAPHQL_QUERY, "variables": {"org": org_name, "after": after}},
360+
) as resp:
361+
if resp.status != 200:
362+
log.warning("GitHub GraphQL API returned status %d when fetching org teams", resp.status)
363+
return None
364+
data = await resp.json()
365+
366+
if data.get("errors"):
367+
log.warning("GitHub GraphQL API returned errors when fetching org teams: %s", data["errors"])
368+
return None
369+
370+
organization = data.get("data", {}).get("organization")
371+
if organization is None:
372+
log.warning("GitHub GraphQL API did not return organization %s", org_name)
373+
return None
374+
375+
teams = organization.get("teams") or {}
376+
for team in teams.get("nodes") or []:
377+
slug = team.get("slug") if isinstance(team, dict) else None
378+
if isinstance(slug, str) and slug:
379+
team_slugs.add(slug)
380+
381+
page_info = teams.get("pageInfo") or {}
382+
if not page_info.get("hasNextPage"):
383+
break
384+
after = page_info.get("endCursor")
385+
except Exception as e:
386+
log.warning("Error fetching GitHub org teams with GraphQL for org %s: %s", org_name, e)
387+
return None
388+
389+
return team_slugs
390+
391+
392+
async def fetch_github_team_members_table_graphql(
393+
access_token: str,
394+
org_name: str,
395+
team_keys: list[str],
396+
) -> dict[str, list[str]] | None:
397+
"""Fetch a login -> configured team-key table with batched GraphQL team queries."""
398+
if not access_token or not org_name or not team_keys:
399+
return {}
400+
401+
headers = {
402+
"Authorization": f"Bearer {access_token}",
403+
"Accept": "application/vnd.github+json",
404+
"X-GitHub-Api-Version": "2022-11-28",
405+
}
406+
teams_by_login: dict[str, list[str]] = {}
407+
actual_team_slugs = await fetch_github_org_team_slugs_graphql(access_token, org_name)
408+
if actual_team_slugs is None:
409+
return None
410+
411+
existing_team_keys = []
412+
for team_key in team_keys:
413+
api_slug = _github_team_api_slug(team_key)
414+
if api_slug in actual_team_slugs:
415+
existing_team_keys.append(team_key)
416+
else:
417+
log.warning("GitHub org %s does not have configured team %s", org_name, api_slug)
418+
419+
pending = dict.fromkeys(existing_team_keys)
420+
421+
try:
422+
async with aiohttp.ClientSession() as session:
423+
while pending:
424+
batch_keys = list(pending)
425+
variables: dict[str, str | None] = {"org": org_name}
426+
for index, team_key in enumerate(batch_keys):
427+
variables[f"slug{index}"] = _github_team_api_slug(team_key)
428+
variables[f"after{index}"] = pending[team_key]
429+
430+
async with session.post(
431+
"https://api.github.com/graphql",
432+
headers=headers,
433+
json={
434+
"query": _build_github_team_members_graphql_query(len(batch_keys)),
435+
"variables": variables,
436+
},
437+
) as resp:
438+
if resp.status != 200:
439+
log.warning("GitHub GraphQL API returned status %d when fetching team members", resp.status)
440+
return None
441+
data = await resp.json()
442+
443+
if data.get("errors"):
444+
log.warning("GitHub GraphQL API returned errors when fetching team members: %s", data["errors"])
445+
return None
446+
447+
organization = data.get("data", {}).get("organization")
448+
if organization is None:
449+
log.warning("GitHub GraphQL API did not return organization %s", org_name)
450+
return None
451+
452+
next_pending: dict[str, str | None] = {}
453+
for index, team_key in enumerate(batch_keys):
454+
team = organization.get(f"team{index}")
455+
if team is None:
456+
log.warning(
457+
"GitHub GraphQL API did not return team %s in org %s",
458+
_github_team_api_slug(team_key),
459+
org_name,
460+
)
461+
continue
462+
463+
members = team.get("members") or {}
464+
for member in members.get("nodes") or []:
465+
login = member.get("login") if isinstance(member, dict) else None
466+
if isinstance(login, str) and login:
467+
teams_by_login.setdefault(login.lower(), []).append(team_key)
468+
469+
page_info = members.get("pageInfo") or {}
470+
if page_info.get("hasNextPage"):
471+
next_pending[team_key] = page_info.get("endCursor")
472+
473+
pending = next_pending
474+
except Exception as e:
475+
log.warning("Error fetching GitHub team members with GraphQL for org %s: %s", org_name, e)
476+
return None
477+
478+
return teams_by_login
479+
480+
293481
async def fetch_github_team_members_table(
294482
app_id: str,
295483
installation_id: str,
@@ -329,13 +517,13 @@ async def fetch_github_team_members_table(
329517
if not installation_token:
330518
return None
331519

332-
teams_by_login: dict[str, list[str]] = {}
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))
335-
if members is None:
336-
return None
337-
for login in members:
338-
teams_by_login.setdefault(login, []).append(team_key)
520+
teams_by_login = await fetch_github_team_members_table_graphql(
521+
installation_token,
522+
org_name,
523+
github_team_keys,
524+
)
525+
if teams_by_login is None:
526+
return None
339527

340528
_GITHUB_TEAM_MEMBERS_CACHE[cache_key] = (now, teams_by_login)
341529
return {login: list(teams) for login, teams in teams_by_login.items()}

runtime/hub/tests/test_groups.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class DummyClientSession:
124124
created = 0
125125
get_calls = 0
126126
post_calls = 0
127+
graphql_calls = 0
127128

128129
def __init__(self):
129130
type(self).created += 1
@@ -144,17 +145,55 @@ def get(self, url, headers=None):
144145
return DummyResponse(200, [{"login": "OctoUser"}])
145146
return DummyResponse(200, {"repositories": []})
146147

147-
def post(self, url, headers=None):
148+
def post(self, url, headers=None, json=None):
148149
type(self).post_calls += 1
149150
if url.endswith("/app/installations/12345/access_tokens"):
150151
return DummyResponse(201, {"token": "cached-token", "expires_at": "2099-01-01T00:00:00Z"})
152+
if url == "https://api.github.com/graphql":
153+
type(self).graphql_calls += 1
154+
if "teams(first: 100" in (json or {}).get("query", ""):
155+
return DummyResponse(
156+
200,
157+
{
158+
"data": {
159+
"organization": {
160+
"teams": {
161+
"nodes": [{"name": "AUP", "slug": "aup"}],
162+
"pageInfo": {"hasNextPage": False, "endCursor": None},
163+
}
164+
}
165+
}
166+
},
167+
)
168+
169+
organization = {}
170+
variables = (json or {}).get("variables", {})
171+
for key, value in variables.items():
172+
if not key.startswith("slug"):
173+
continue
174+
index = key.removeprefix("slug")
175+
if value == "missing-team":
176+
organization[f"team{index}"] = None
177+
elif value == "aup":
178+
organization[f"team{index}"] = {
179+
"members": {
180+
"nodes": [{"login": "OctoUser"}],
181+
"pageInfo": {"hasNextPage": False, "endCursor": None},
182+
}
183+
}
184+
else:
185+
organization[f"team{index}"] = {
186+
"members": {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": None}}
187+
}
188+
return DummyResponse(200, {"data": {"organization": organization}})
151189
return DummyResponse(500, {})
152190

153191

154192
def _reset_dummy_client_session():
155193
DummyClientSession.created = 0
156194
DummyClientSession.get_calls = 0
157195
DummyClientSession.post_calls = 0
196+
DummyClientSession.graphql_calls = 0
158197
groups._GITHUB_APP_INSTALLATION_ID_CACHE.clear()
159198
groups._GITHUB_APP_INSTALLATION_TOKEN.clear()
160199
groups._GITHUB_TEAM_MEMBERS_CACHE.clear()
@@ -228,6 +267,30 @@ def test_fetch_github_team_members_table_uses_api_slug_and_preserves_group_key(m
228267
)
229268

230269
assert teams_by_login == {"octouser": ["AUP"]}
270+
assert DummyClientSession.graphql_calls == 2
271+
272+
273+
def test_fetch_github_team_members_table_skips_missing_graphql_team(monkeypatch, caplog):
274+
_reset_dummy_client_session()
275+
monkeypatch.setattr(groups.aiohttp, "ClientSession", DummyClientSession)
276+
monkeypatch.setattr(groups.jwt, "encode", lambda payload, private_key, algorithm: "jwt-token")
277+
278+
caplog.set_level("WARNING", logger="jupyterhub.groups")
279+
teams_by_login = asyncio.run(
280+
fetch_github_team_members_table(
281+
"app-123",
282+
"",
283+
"dummy-private-key",
284+
"",
285+
"test-org",
286+
{"AUP", "missing-team"},
287+
force=True,
288+
)
289+
)
290+
291+
assert teams_by_login == {"octouser": ["AUP"]}
292+
assert "configured team missing-team" in caplog.text
293+
assert DummyClientSession.graphql_calls == 2
231294

232295

233296
def test_resolve_resources_for_user_uses_group_mapping():

0 commit comments

Comments
 (0)