|
2 | 2 |
|
3 | 3 | from typing import TYPE_CHECKING |
4 | 4 |
|
| 5 | +import discord |
5 | 6 | from discord.ext import tasks |
6 | 7 | from discord.ext.commands import Cog |
7 | 8 |
|
| 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 | + |
8 | 18 | if TYPE_CHECKING: |
9 | 19 | from arthur.bot import KingArthurTheTerrible |
10 | 20 |
|
11 | 21 |
|
12 | 22 | class GitHubManagement(Cog): |
13 | 23 | """GitHub organisation membership synchronisation with LDAP.""" |
14 | 24 |
|
| 25 | + MAX_REPORT_MESSAGE_LENGTH = 1900 |
| 26 | + DRY_RUN_CHANNEL_ID = 675756741417369640 |
| 27 | + DRY_RUN_THREAD_ID = 1265289413433364511 |
| 28 | + |
15 | 29 | def __init__(self, bot: KingArthurTheTerrible) -> None: |
16 | 30 | self.bot = bot |
17 | 31 |
|
18 | 32 | @tasks.loop(minutes=10) |
19 | 33 | 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 |
21 | 228 |
|
22 | 229 |
|
23 | 230 | async def setup(bot: KingArthurTheTerrible) -> None: |
|
0 commit comments