Skip to content

Commit 7e236e2

Browse files
committed
Implementation of team sync (with regards to Codex)
1 parent 34bf0ec commit 7e236e2

3 files changed

Lines changed: 269 additions & 6 deletions

File tree

arthur/apis/directory/keycloak.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,27 @@ async def get_user_github_id(username: str) -> str | None:
4949
return github_id
5050

5151

52-
async def all_github_ids() -> list[str]:
53-
"""Fetch all GitHub IDs from Keycloak."""
52+
async def all_github_identities() -> dict[str, dict[str, str]]:
53+
"""Fetch Keycloak usernames and their linked GitHub identity information."""
5454
client = create_client()
5555

5656
users = client.get_users()
57-
github_ids = []
57+
github_identities = {}
5858

5959
for user in users:
6060
user_details = client.get_user(user["id"])
6161
for ident in user_details["federatedIdentities"]:
6262
if ident["identityProvider"] == "github":
63-
github_ids.append(ident["userId"]) # noqa: PERF401
63+
github_identities[user_details["username"]] = {
64+
"user_id": ident.get("userId", ""),
65+
"user_name": ident.get("userName", ""),
66+
}
67+
break
68+
69+
return github_identities
6470

65-
return github_ids
71+
72+
async def all_github_ids() -> list[str]:
73+
"""Fetch all GitHub IDs from Keycloak."""
74+
identities = await all_github_identities()
75+
return [ident["user_id"] for ident in identities.values() if ident.get("user_id")]

arthur/apis/github.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ async def remove_org_member(username: str) -> None:
4343
raise GitHubError(msg)
4444

4545

46+
async def add_org_member(username: str) -> None:
47+
"""Add a user to the GitHub organisation."""
48+
async with aiohttp.ClientSession() as session:
49+
endpoint = f"https://api.github.com/orgs/{CONFIG.github_org}/memberships/{username}"
50+
async with session.put(endpoint, headers=HEADERS, json={"role": "member"}) as response:
51+
try:
52+
response.raise_for_status()
53+
except aiohttp.ClientResponseError as e:
54+
if e.status == HTTPStatus.NOT_FOUND:
55+
msg = f"User not found: {e.message}"
56+
raise GitHubError(msg)
57+
if e.status == HTTPStatus.FORBIDDEN:
58+
msg = f"Forbidden: {e.message}"
59+
raise GitHubError(msg)
60+
61+
msg = f"Unexpected error: {e.message}"
62+
raise GitHubError(msg)
63+
64+
4665
async def add_member_to_team(username: str, github_team_slug: str) -> None:
4766
"""Add a user to a GitHub team."""
4867
async with aiohttp.ClientSession() as session:
@@ -66,6 +85,33 @@ async def add_member_to_team(username: str, github_team_slug: str) -> None:
6685
raise GitHubError(msg)
6786

6887

88+
async def list_team_members(github_team_slug: str) -> list[str]:
89+
"""List all members of a GitHub team, and handle pagination."""
90+
members = []
91+
page = 1
92+
per_page = 100
93+
94+
async with aiohttp.ClientSession() as session:
95+
while True:
96+
endpoint = (
97+
f"https://api.github.com/orgs/{CONFIG.github_org}/teams/{github_team_slug}"
98+
f"/members?per_page={per_page}&page={page}"
99+
)
100+
async with session.get(endpoint, headers=HEADERS) as response:
101+
try:
102+
response.raise_for_status()
103+
data = await response.json()
104+
members.extend([member["login"] for member in data])
105+
if len(data) < per_page:
106+
break
107+
page += 1
108+
except aiohttp.ClientResponseError as e:
109+
msg = f"Failed to list team members for {github_team_slug}: {e.message}"
110+
raise GitHubError(msg)
111+
112+
return members
113+
114+
69115
async def remove_member_from_team(username: str, github_team_slug: str) -> None:
70116
"""Remove a user from a GitHub team."""
71117
async with aiohttp.ClientSession() as session:

arthur/exts/github/management.py

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,229 @@
22

33
from typing import TYPE_CHECKING
44

5+
import discord
56
from discord.ext import tasks
67
from discord.ext.commands import Cog
78

9+
from arthur.apis.directory import ldap
10+
from arthur.apis.directory.keycloak import all_github_identities
11+
from arthur.apis.github import (
12+
list_organisation_members,
13+
list_team_members,
14+
)
15+
from arthur.constants import LDAP_ROLE_MAPPING
16+
from arthur.log import logger
17+
818
if TYPE_CHECKING:
919
from arthur.bot import KingArthurTheTerrible
1020

1121

1222
class GitHubManagement(Cog):
1323
"""GitHub organisation membership synchronisation with LDAP."""
1424

25+
MAX_REPORT_MESSAGE_LENGTH = 1900
26+
DRY_RUN_CHANNEL_ID = 675756741417369640
27+
DRY_RUN_THREAD_ID = 1265289413433364511
28+
1529
def __init__(self, bot: KingArthurTheTerrible) -> None:
1630
self.bot = bot
1731

1832
@tasks.loop(minutes=10)
1933
async def sync_github_org(self) -> None:
20-
"""Synchronise GitHub organisation membership with LDAP."""
34+
"""
35+
Synchronise GitHub organisation membership with LDAP.
36+
37+
This consists of two components, a synchronisation of GitHub org membership and then a separate
38+
sync of GitHub team membership.
39+
40+
The organisation sync works as follows:
41+
1. Fetch all users from Keycloak and their GitHub IDs.
42+
2. Fetch all GitHub members of the organisation.
43+
3. Compare the two lists and determine which users need to be added or removed from the organisation.
44+
45+
The team sync works as follows:
46+
1. For each LDAP group, fetch the corresponding GitHub team.
47+
2. For each team, fetch the current members of the team.
48+
3. For each team, determine which users need to be added or removed from the team based on their LDAP group membership.
49+
"""
50+
try:
51+
report_thread = await self._get_dry_run_thread()
52+
if report_thread is None:
53+
logger.error(
54+
"GitHub: Dry-run thread not found "
55+
f"(channel={self.DRY_RUN_CHANNEL_ID}, thread={self.DRY_RUN_THREAD_ID})."
56+
)
57+
return
58+
59+
await report_thread.send(
60+
":mag: **GitHub membership dry-run started**\n"
61+
"No changes will be applied. This is a report of what *would* happen."
62+
)
63+
64+
added_org, removed_org = await self._sync_github_members(report_thread)
65+
added_team, removed_team = await self._sync_github_teams(report_thread)
66+
67+
logger.info(
68+
"GitHub: Dry-run complete. "
69+
f"Org added={added_org}, org removed={removed_org}, "
70+
f"team added={added_team}, team removed={removed_team}."
71+
)
72+
73+
await report_thread.send(
74+
":white_check_mark: **GitHub membership dry-run complete**\n"
75+
f":office: Org changes: +{added_org} / -{removed_org}\n"
76+
f":busts_in_silhouette: Team changes: +{added_team} / -{removed_team}"
77+
)
78+
except Exception as e: # noqa: BLE001
79+
logger.exception(f"GitHub: Error during sync: {e}", exc_info=True)
80+
report_thread = await self._get_dry_run_thread()
81+
if report_thread is not None:
82+
await report_thread.send(f":x: GitHub dry-run error: ```python\n{e}```")
83+
84+
async def _get_dry_run_thread(self) -> discord.Thread | None:
85+
"""Resolve the configured dry-run thread, fetching it when not cached."""
86+
channel = self.bot.get_channel(self.DRY_RUN_CHANNEL_ID)
87+
if not isinstance(channel, discord.TextChannel):
88+
fetched_channel = await self.bot.fetch_channel(self.DRY_RUN_CHANNEL_ID)
89+
if not isinstance(fetched_channel, discord.TextChannel):
90+
return None
91+
channel = fetched_channel
92+
93+
thread = self.bot.get_channel(self.DRY_RUN_THREAD_ID)
94+
if isinstance(thread, discord.Thread):
95+
return thread
96+
97+
fetched_thread = await self.bot.fetch_channel(self.DRY_RUN_THREAD_ID)
98+
if not isinstance(fetched_thread, discord.Thread):
99+
return None
100+
101+
if fetched_thread.parent_id != channel.id:
102+
logger.warning(
103+
"GitHub: Dry-run thread parent mismatch "
104+
f"(expected={channel.id}, actual={fetched_thread.parent_id})."
105+
)
106+
107+
return fetched_thread
108+
109+
async def _send_report_lines(self, report_thread: discord.Thread, lines: list[str]) -> None:
110+
"""Send report lines in chunks that fit Discord's message length limit."""
111+
message = ""
112+
for line in lines:
113+
next_message = f"{message}\n{line}" if message else line
114+
if len(next_message) > self.MAX_REPORT_MESSAGE_LENGTH:
115+
await report_thread.send(message)
116+
message = line
117+
continue
118+
message = next_message
119+
120+
if message:
121+
await report_thread.send(message)
122+
123+
async def cog_load(self) -> None:
124+
"""Start the GitHub synchronisation task."""
125+
self.sync_github_org.start()
126+
127+
async def cog_unload(self) -> None:
128+
"""Stop the GitHub synchronisation task."""
129+
self.sync_github_org.cancel()
130+
131+
async def _fetch_common_info(self) -> tuple[dict[str, dict[str, str]], set[str]]:
132+
"""Fetch common data needed for both GitHub org and team synchronisation."""
133+
keycloak_identities = await all_github_identities()
134+
github_org_members = set(await list_organisation_members())
135+
136+
return keycloak_identities, github_org_members
137+
138+
async def _sync_github_members(self, report_thread: discord.Thread) -> tuple[int, int]:
139+
"""Dry-run GitHub organisation membership synchronisation with Keycloak."""
140+
keycloak_identities, github_org_members = await self._fetch_common_info()
141+
142+
desired_org_members = {
143+
identity["user_name"]
144+
for identity in keycloak_identities.values()
145+
if identity.get("user_name")
146+
}
147+
148+
to_add = desired_org_members - github_org_members
149+
to_remove = github_org_members - desired_org_members
150+
151+
added = 0
152+
removed = 0
153+
154+
add_lines = [f":green_circle: would add to org: `{username}`" for username in sorted(to_add)]
155+
remove_lines = [
156+
f":red_circle: would remove from org: `{username}`" for username in sorted(to_remove)
157+
]
158+
159+
if not add_lines:
160+
add_lines = [":white_circle: no org additions needed"]
161+
if not remove_lines:
162+
remove_lines = [":white_circle: no org removals needed"]
163+
164+
await self._send_report_lines(
165+
report_thread,
166+
[":office: **Org dry-run decisions**", *add_lines, *remove_lines],
167+
)
168+
169+
added = len(to_add)
170+
removed = len(to_remove)
171+
172+
return added, removed
173+
174+
async def _sync_github_teams(self, report_thread: discord.Thread) -> tuple[int, int]:
175+
"""Dry-run GitHub team membership synchronisation with Keycloak."""
176+
keycloak_identities, _ = await self._fetch_common_info()
177+
keycloak_to_github = {
178+
keycloak_username: identity["user_name"]
179+
for keycloak_username, identity in keycloak_identities.items()
180+
if identity.get("user_name")
181+
}
182+
183+
added = 0
184+
removed = 0
185+
186+
for ldap_group, mapping in LDAP_ROLE_MAPPING.items():
187+
github_team_slug = mapping["github_team_slug"]
188+
ldap_members = await ldap.get_group_members(ldap_group)
189+
desired_team_members = {
190+
keycloak_to_github[member.uid]
191+
for member in ldap_members
192+
if member.uid in keycloak_to_github
193+
}
194+
195+
current_team_members = set(await list_team_members(github_team_slug))
196+
197+
to_add = desired_team_members - current_team_members
198+
to_remove = current_team_members - desired_team_members
199+
200+
add_lines = [
201+
f":green_circle: would add to `{github_team_slug}`: `{username}`"
202+
for username in sorted(to_add)
203+
]
204+
remove_lines = [
205+
f":red_circle: would remove from `{github_team_slug}`: `{username}`"
206+
for username in sorted(to_remove)
207+
]
208+
209+
if not add_lines and not remove_lines:
210+
await self._send_report_lines(
211+
report_thread,
212+
[f":white_circle: **Team `{github_team_slug}`**: no membership changes needed"],
213+
)
214+
else:
215+
await self._send_report_lines(
216+
report_thread,
217+
[
218+
f":busts_in_silhouette: **Team `{github_team_slug}` dry-run decisions**",
219+
*add_lines,
220+
*remove_lines,
221+
],
222+
)
223+
224+
added += len(to_add)
225+
removed += len(to_remove)
226+
227+
return added, removed
21228

22229

23230
async def setup(bot: KingArthurTheTerrible) -> None:

0 commit comments

Comments
 (0)