From b2c900b5d7897953f62803f31613aa28da557924 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 02:47:38 +0700 Subject: [PATCH 01/11] fix(setup): recover guild_id when GitHub drops state on already-installed apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: when the GitHub App is already installed on an account/org, GitHub redirects to the installation settings page instead of the fresh install page, dropping the state JWT. Any subsequent Save triggers the setup URL with setup_action=update and no state — CASE 1 showed 'run /setup again' which looped forever. Fix — three-part: 1. shared/firestore.py — add three new methods to FirestoreMultiTenant: - set_pending_setup(guild_id, guild_name): stores a short-lived pending_setups/{guild_id} doc with an ISO timestamp before GitHub redirect - pop_recent_pending_setup(max_age_seconds=600): returns + deletes the most recent pending setup within the window, or None - find_guild_by_installation_id(installation_id): queries discord_servers for a guild already linked to that installation 2. auth.py /github/app/install: calls set_pending_setup() before redirecting to GitHub so the record exists when the callback fires 3. auth.py CASE 1 (no state, has installation_id) — now has three sub-cases: A. Installation already linked to a guild: - setup_action=update → 'Repository Access Updated' page with link back to the correct Discord server; no loop - otherwise → 'Already Connected' page B. Pending setup found (within 10 min): complete setup directly — save config, trigger initial sync, notify Discord, show 'Setup Complete!' page C. No pending setup (owner approved from GitHub email etc.): original 'Installation Approved, run /setup' page — still correct for that case --- discord_bot/src/bot/auth.py | 115 ++++++++++++++++++++++++++++++++---- shared/firestore.py | 57 ++++++++++++++++++ 2 files changed, 162 insertions(+), 10 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index a0915b9..9517b15 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -919,6 +919,15 @@ def github_app_install(): state = state_serializer.dumps({'guild_id': str(guild_id), 'guild_name': guild_name}) install_url = f"https://github.com/apps/{app_slug}/installations/new?state={state}" + + # Store pending setup so /github/app/setup can recover guild_id if GitHub + # drops the state param (app already installed → setup_action=update flow). + try: + from shared.firestore import get_mt_client as _get_mt + _get_mt().set_pending_setup(str(guild_id), guild_name) + except Exception as _e: + print(f"Warning: could not store pending setup for guild {guild_id}: {_e}") + return redirect(install_url) @app.route("/github/app/setup") @@ -926,7 +935,7 @@ def github_app_setup(): """GitHub App 'Setup URL' callback: stores installation ID for a Discord server.""" from flask import request, render_template_string from shared.firestore import get_mt_client - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone from src.services.github_app_service import GitHubAppService installation_id = request.args.get('installation_id') @@ -934,20 +943,106 @@ def github_app_setup(): state = request.args.get('state', '') # --- CASE 1: No state parameter --- - # This happens when an org owner approves a request from GitHub directly. - # GitHub redirects the owner to the Setup URL WITHOUT state, because state - # was generated in the non-owner's session. + # GitHub drops state in two situations: + # (a) Org owner approves a request from the GitHub notification email + # (b) App was already installed → GitHub redirects to settings page → + # user adds/removes repos → clicks Save → setup_action=update, no state + # Fix: before redirecting to GitHub we store a pending_setup record in + # Firestore. Here we try to recover guild_id from that record and complete + # setup directly, avoiding an infinite "run /setup again" loop. if not state: if installation_id: - # Owner approved the installation from GitHub. - # Tell them to run /setup in Discord to complete the link. gh_app = GitHubAppService() installation = gh_app.get_installation(int(installation_id)) - github_org = '' - if installation: - account = installation.get('account') or {} - github_org = account.get('login', '') + account = (installation.get('account') or {}) if installation else {} + github_org = account.get('login', '') + github_account_type = account.get('type', '') + + mt_client = get_mt_client() + + # --- SUB-CASE A: installation already linked to a guild --- + existing_guild_id = mt_client.find_guild_by_installation_id(int(installation_id)) + if existing_guild_id: + if setup_action == 'update': + # User updated repo access on an existing installation — nothing to do + discord_url = f"https://discord.com/channels/{existing_guild_id}" + return render_status_page( + title="Repository Access Updated", + subtitle=f"DisgitBot's repository access on {github_org} has been updated." if github_org else "DisgitBot's repository access has been updated.", + icon_type="success", + instructions=[ + "Your Discord server is still connected — no further action needed.", + "Run /sync to refresh stats with the updated repository list.", + ], + button_text="Open Discord", + button_url=discord_url + ) + # Already linked, no update action — just confirm + discord_url = f"https://discord.com/channels/{existing_guild_id}" + return render_status_page( + title="Already Connected", + subtitle=f"DisgitBot is already connected to {github_org}.", + icon_type="success", + instructions=["Your Discord server is already set up. No further action needed."], + button_text="Open Discord", + button_url=discord_url + ) + + # --- SUB-CASE B: not yet linked — check for recent pending setup --- + pending = mt_client.pop_recent_pending_setup(max_age_seconds=600) + if pending: + guild_id = str(pending['guild_id']) + guild_name = pending.get('guild_name', 'your server') + + if not installation: + # Couldn't verify with GitHub — restore the record so user can retry + mt_client.set_pending_setup(guild_id, guild_name) + return render_status_page( + title="Installation Not Found", + subtitle="We couldn't verify the GitHub installation. Please try again.", + icon_type="error", + button_text="Try Again", + button_url="https://discord.com/app" + ), 500 + + existing_config = mt_client.get_server_config(guild_id) or {} + save_ok = mt_client.set_server_config(guild_id, { + **existing_config, + 'github_org': github_org, + 'github_installation_id': int(installation_id), + 'github_account': account.get('login'), + 'github_account_type': github_account_type, + 'setup_source': 'github_app', + 'created_at': datetime.now(timezone.utc).isoformat(), + 'setup_completed': True, + }) + if not save_ok: + return render_status_page( + title="Storage Error", + subtitle="We couldn't save your server configuration. Please try again.", + icon_type="error", + button_text="Try Again", + button_url="https://discord.com/app" + ), 500 + + trigger_initial_sync(guild_id, github_org, int(installation_id)) + notify_setup_complete(guild_id, github_org) + + discord_url = f"https://discord.com/channels/{guild_id}" + return render_status_page( + title="Setup Complete!", + subtitle=f"{guild_name} is now connected to {github_org}.", + icon_type="success", + instructions=[ + "Your Discord server is now connected to GitHub.", + "Users can run /link to connect their accounts.", + "Stats will be ready in 5–10 minutes.", + ], + button_text="Open Discord", + button_url=discord_url + ) + # --- SUB-CASE C: no pending setup found (owner approved from email etc.) --- return render_status_page( title="Installation Approved!", subtitle=f"DisgitBot has been installed on {github_org}." if github_org else "DisgitBot has been installed successfully.", diff --git a/shared/firestore.py b/shared/firestore.py index 9974c71..4531bf5 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -94,6 +94,63 @@ def get_org_from_server(self, discord_server_id: str) -> Optional[str]: server_config = self.get_server_config(discord_server_id) return server_config.get('github_org') if server_config else None + def set_pending_setup(self, guild_id: str, guild_name: str) -> bool: + """Store a short-lived pending setup record before GitHub redirect. + + This lets /github/app/setup recover the guild_id when GitHub drops the + state param (e.g. app already installed, setup_action=update). + """ + from datetime import datetime, timezone + try: + self.db.collection('pending_setups').document(str(guild_id)).set({ + 'guild_id': str(guild_id), + 'guild_name': guild_name, + 'initiated_at': datetime.now(timezone.utc).isoformat(), + }) + return True + except Exception as e: + print(f"Error storing pending setup for guild {guild_id}: {e}") + return False + + def pop_recent_pending_setup(self, max_age_seconds: int = 600) -> Optional[Dict[str, Any]]: + """Return and delete the most recent pending setup within max_age_seconds. + + Returns None if no recent pending setup exists. + ISO 8601 strings sort lexicographically, so >= on the cutoff string works. + """ + from datetime import datetime, timezone, timedelta + try: + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=max_age_seconds)).isoformat() + docs = list( + self.db.collection('pending_setups') + .where('initiated_at', '>=', cutoff) + .order_by('initiated_at', direction=firestore.Query.DESCENDING) + .limit(1) + .stream() + ) + if not docs: + return None + data = docs[0].to_dict() + docs[0].reference.delete() + return data + except Exception as e: + print(f"Error popping recent pending setup: {e}") + return None + + def find_guild_by_installation_id(self, installation_id: int) -> Optional[str]: + """Return the Discord guild_id that has the given GitHub App installation_id, or None.""" + try: + docs = list( + self.db.collection('discord_servers') + .where('github_installation_id', '==', installation_id) + .limit(1) + .stream() + ) + return docs[0].id if docs else None + except Exception as e: + print(f"Error finding guild by installation_id {installation_id}: {e}") + return None + def _get_credentials_path() -> str: """Get the path to Firebase credentials file. From 521c96f5f8cf93de5a29556b2d1dbc322a5dc295 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:45:31 +0700 Subject: [PATCH 02/11] fix: remove public setup URL from on_guild_join message The join message exposed a direct /setup URL that any member could click to hijack the GitHub setup before an admin. Now directs users to ask an admin to run /setup instead. --- discord_bot/src/bot/bot.py | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index b1a1604..11ce97e 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -92,36 +92,19 @@ async def on_guild_join(guild): system_channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) if system_channel: - base_url = os.getenv("OAUTH_BASE_URL") - from urllib.parse import urlencode - setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" + setup_message = """**DisgitBot Added Successfully!** 🎉 - setup_message = f"""**DisgitBot Added Successfully!** +A server **admin** needs to run `/setup` to connect this server to a GitHub organization. -This server needs to be configured to track GitHub contributions. +**After setup, members can use:** +• `/link` — Connect your GitHub account +• `/getstats` — View contribution statistics +• `/halloffame` — Top contributors leaderboard +• `/configure roles` — Auto-assign roles based on contributions -**Quick Setup (30 seconds):** -1. Visit: {setup_url} -2. Install the GitHub App and select repositories -3. Use `/link` in Discord to connect GitHub accounts -4. Customize roles with `/configure roles` - -**Or use this command:** `/setup` - -After setup, try these commands: -• `/getstats` - View contribution statistics -• `/halloffame` - Top contributors leaderboard -• `/link` - Connect your GitHub account - -*This message will only appear once during setup.*""" +*This message will only appear once.*""" await system_channel.send(setup_message) - - # Mark reminder as sent - await asyncio.to_thread(mt_client.set_server_config, str(guild.id), { - **server_config, - 'setup_reminder_sent_at': datetime.now(timezone.utc).isoformat() - }) print(f"Sent setup guidance to server: {guild.name} (ID: {guild.id})") except Exception as e: From 35f90d991d8d95d5a3697fcb0a6a6c51b1522f5b Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:45:44 +0700 Subject: [PATCH 03/11] fix: guard /complete_setup and use atomic transaction for setup - Add setup_completed guard to /complete_setup POST endpoint to prevent unauthorized config overrides on this dead-code route - Replace set_server_config with complete_setup_atomically in both Sub-Case B and Case 3 callbacks to eliminate double 'Setup Complete' notifications from racing GitHub callbacks - Update Sub-Case C page text for clarity --- discord_bot/src/bot/auth.py | 97 ++++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 9517b15..e26fba4 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1005,9 +1005,7 @@ def github_app_setup(): button_url="https://discord.com/app" ), 500 - existing_config = mt_client.get_server_config(guild_id) or {} - save_ok = mt_client.set_server_config(guild_id, { - **existing_config, + save_ok = mt_client.complete_setup_atomically(guild_id, { 'github_org': github_org, 'github_installation_id': int(installation_id), 'github_account': account.get('login'), @@ -1017,13 +1015,16 @@ def github_app_setup(): 'setup_completed': True, }) if not save_ok: + # Transaction returned False — a racing request already completed setup + discord_url = f"https://discord.com/channels/{guild_id}" return render_status_page( - title="Storage Error", - subtitle="We couldn't save your server configuration. Please try again.", - icon_type="error", - button_text="Try Again", - button_url="https://discord.com/app" - ), 500 + title="Already Connected", + subtitle=f"{guild_name} has already been set up.", + icon_type="success", + instructions=["No further action needed."], + button_text="Open Discord", + button_url=discord_url + ) trigger_initial_sync(guild_id, github_org, int(installation_id)) notify_setup_complete(guild_id, github_org) @@ -1049,7 +1050,7 @@ def github_app_setup(): icon_type="success", instructions=[ "Go back to your Discord server.", - "Run /setup to link this GitHub installation to your server.", + "An admin or the server owner should run /setup to link this GitHub installation to your server.", ], button_text="Open Discord", button_url="https://discord.com/app" @@ -1168,8 +1169,24 @@ def github_app_setup(): mt_client = get_mt_client() existing_config = mt_client.get_server_config(guild_id) or {} - success = mt_client.set_server_config(guild_id, { - **existing_config, + + # --- Guard: prevent override if already set up with a DIFFERENT installation --- + existing_install_id = existing_config.get('github_installation_id') + if existing_config.get('setup_completed') and existing_install_id and existing_install_id != int(installation_id): + existing_org = existing_config.get('github_org', 'a GitHub account') + return render_status_page( + title="Server Already Set Up", + subtitle=f"This Discord server is already connected to {existing_org}. The existing setup was not changed.", + icon_type="warning", + instructions=[ + "If you intended to reconfigure, please contact the server admin.", + "Only the server admin can reset and reconnect to a different GitHub organization.", + ], + button_text="Open Discord", + button_url=f"https://discord.com/channels/{guild_id}" + ), 403 + + success = mt_client.complete_setup_atomically(guild_id, { 'github_org': github_org, 'github_installation_id': int(installation_id), 'github_account': github_account, @@ -1180,16 +1197,19 @@ def github_app_setup(): }) if not success: + # Transaction returned False — a racing request already completed setup + discord_url = f"https://discord.com/channels/{guild_id}" return render_status_page( - title="Storage Error", - subtitle="We couldn't save your server configuration to our database. Please try again in a few moments.", - icon_type="error" - ), 500 - - + title="Already Connected", + subtitle=f"{guild_name} has already been set up.", + icon_type="success", + instructions=["No further action needed."], + button_text="Open Discord", + button_url=discord_url + ) # Trigger initial sync and Discord notification - sync_triggered = trigger_initial_sync(guild_id, github_org, int(installation_id)) + trigger_initial_sync(guild_id, github_org, int(installation_id)) notify_setup_complete(guild_id, github_org) success_page = """ @@ -1395,6 +1415,24 @@ def setup(): button_url="https://discord.com/app" ), 400 + # --- Guard: block re-setup if guild is already fully configured --- + from shared.firestore import get_mt_client as _setup_mt + _existing = _setup_mt().get_server_config(guild_id) or {} + if _existing.get('setup_completed'): + discord_url = f"https://discord.com/channels/{guild_id}" + existing_org = _existing.get('github_org', 'GitHub') + return render_status_page( + title="Server Already Set Up", + subtitle=f"This Discord server is already connected to {existing_org}. Setup cannot be run again from this link.", + icon_type="info", + instructions=[ + "If you need to change the GitHub organization, ask the server admin to run /setup in Discord.", + "To update which repositories are tracked, go to your GitHub App installation settings.", + ], + button_text="Open Discord", + button_url=discord_url + ), 400 + github_app_install_url = f"{base_url}/github/app/install?{urlencode({'guild_id': guild_id, 'guild_name': guild_name})}" setup_page = """ @@ -1582,7 +1620,22 @@ def complete_setup(): button_text="Try Again", button_url=f"https://discord.com/channels/{guild_id}" if guild_id else "https://discord.com/app" ), 400 - + + # --- Guard: block re-setup if guild is already fully configured --- + mt_client = get_mt_client() + existing_config = mt_client.get_server_config(guild_id) or {} + if existing_config.get('setup_completed'): + discord_url = f"https://discord.com/channels/{guild_id}" + existing_org = existing_config.get('github_org', 'GitHub') + return render_status_page( + title="Server Already Set Up", + subtitle=f"This Discord server is already connected to {existing_org}.", + icon_type="info", + instructions=["Setup cannot be completed again from this route."], + button_text="Open Discord", + button_url=discord_url + ), 400 + # Validate GitHub organization name (basic validation) if not github_org.replace('-', '').replace('_', '').isalnum(): return render_status_page( @@ -1592,10 +1645,8 @@ def complete_setup(): button_text="Try Again", button_url=f"https://discord.com/channels/{guild_id}" ), 400 - + try: - # Store server configuration - mt_client = get_mt_client() success = mt_client.set_server_config(guild_id, { 'github_org': github_org, 'setup_source': setup_source, From 816e248a451b40d4f9e1f52efb57fcd0076bcfb2 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:45:55 +0700 Subject: [PATCH 04/11] feat: add complete_setup_atomically Firestore transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses @firestore.transactional to atomically check setup_completed and write config. If two GitHub callbacks race, only the first one succeeds — the second returns False without writing. --- shared/firestore.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/shared/firestore.py b/shared/firestore.py index 4531bf5..781bb9c 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -151,6 +151,30 @@ def find_guild_by_installation_id(self, installation_id: int) -> Optional[str]: print(f"Error finding guild by installation_id {installation_id}: {e}") return None + def complete_setup_atomically(self, guild_id: str, config: Dict[str, Any]) -> bool: + """Atomically complete setup — returns True only for the FIRST caller. + + Uses a Firestore transaction to read setup_completed and write config + in one atomic operation. If two GitHub callbacks race, only one wins. + """ + doc_ref = self.db.collection('discord_servers').document(str(guild_id)) + + @firestore.transactional + def _txn(transaction): + snapshot = doc_ref.get(transaction=transaction) + existing = snapshot.to_dict() if snapshot.exists else {} + if existing.get('setup_completed'): + return False # already completed by a racing request + transaction.set(doc_ref, {**existing, **config}) + return True + + try: + transaction = self.db.transaction() + return _txn(transaction) + except Exception as e: + print(f"Error in atomic setup for guild {guild_id}: {e}") + return False + def _get_credentials_path() -> str: """Get the path to Firebase credentials file. From 7bf41b3a927c651e5321ae11a3094155e8d949d2 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:46:07 +0700 Subject: [PATCH 05/11] fix: add admin permission gate to setup_voice_stats command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This command creates/deletes channel categories — destructive actions that should be restricted to server administrators. --- discord_bot/src/bot/commands/admin_commands.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index a9cad97..d557a0b 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -231,6 +231,11 @@ async def setup_voice_stats(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) try: + # Check if user has administrator permissions + if not interaction.user.guild_permissions.administrator: + await interaction.followup.send("Only server administrators can use this command.", ephemeral=True) + return + guild = interaction.guild assert guild is not None, "Command should only work in guilds" From 45bd35d89b4be0b0d98324b64c9c1a86dec0b289 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:46:19 +0700 Subject: [PATCH 06/11] feat: add /repos command to list tracked repositories Shows all repositories the GitHub App has access to for this server. Uses installation access token to query the GitHub API. Also adds /repos to /help output. --- discord_bot/src/bot/commands/user_commands.py | 100 +++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 9481aae..13b4aab 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -55,6 +55,7 @@ def register_commands(self): self.bot.tree.add_command(self._unlink_command()) self.bot.tree.add_command(self._getstats_command()) self.bot.tree.add_command(self._halloffame_command()) + self.bot.tree.add_command(self._repos_command()) def _help_command(self): """Create the help command.""" @@ -90,7 +91,8 @@ async def help_cmd(interaction: discord.Interaction): name="3️⃣ View stats", value=( "`/getstats` — your personal contribution stats\n" - "`/halloffame` — top 3 contributors leaderboard" + "`/halloffame` — top 3 contributors leaderboard\n" + "`/repos` — list all tracked repositories" ), inline=False ) @@ -154,7 +156,7 @@ async def help_cmd(interaction: discord.Interaction): "If a **non-owner** member runs `/setup`, GitHub sends " "an install **request** to the org owner.\n" "After the owner approves on GitHub, " - "someone must run `/setup` again in Discord to complete the link." + "an admin or the owner must run `/setup` again in Discord to complete the link." ), inline=False ) @@ -551,4 +553,96 @@ def _create_halloffame_embed(self, top_3, type, period, last_updated): embed.set_footer(text=f"Last updated: {last_updated or 'Unknown'}") return embed - + + def _repos_command(self): + """Create the repos command to list tracked repositories.""" + @app_commands.command(name="repos", description="List repositories tracked by DisgitBot on this server") + @app_commands.guild_only() + async def repos(interaction: discord.Interaction): + """Shows all repositories the GitHub App can access for this server.""" + await self._safe_defer(interaction) + + try: + mt_client = get_mt_client() + guild_id = str(interaction.guild_id) + server_config = await asyncio.to_thread(mt_client.get_server_config, guild_id) or {} + + if not server_config.get('setup_completed'): + await self._safe_followup( + interaction, + "This server hasn't been set up yet. An admin needs to run `/setup` first." + ) + return + + installation_id = server_config.get('github_installation_id') + github_org = server_config.get('github_org', 'Unknown') + + if not installation_id: + await self._safe_followup( + interaction, + f"This server is connected to **{github_org}** but has no GitHub App installation ID.\n" + f"An admin should run `/setup` to reconnect." + ) + return + + # Get installation access token and fetch repos + from ...services.github_app_service import GitHubAppService + from ...services.github_service import GitHubService + + gh_app = GitHubAppService() + token = await asyncio.to_thread(gh_app.get_installation_access_token, installation_id) + + if not token: + await self._safe_followup( + interaction, + "Couldn't authenticate with GitHub. The app installation may have been removed.\n" + "An admin should check the GitHub App settings or run `/setup` again." + ) + return + + gh_service = GitHubService( + repo_owner=github_org, + token=token, + installation_id=installation_id + ) + repos_list = await asyncio.to_thread(gh_service.fetch_installation_repositories) + + if not repos_list: + embed = discord.Embed( + title="📂 Tracked Repositories", + description=f"Connected to **{github_org}** but no repositories found.", + color=0xfee75c # yellow + ) + embed.set_footer(text="The GitHub App may need repository access permissions updated.") + await self._safe_followup(interaction, embed, embed=True) + return + + # Build a nice embed + embed = discord.Embed( + title="📂 Tracked Repositories", + description=f"**{github_org}** — {len(repos_list)} {'repository' if len(repos_list) == 1 else 'repositories'} tracked", + color=0x43b581 # green + ) + + # Show repos in chunks (Discord embed field limit is 1024 chars) + repo_names = [f"• `{r['owner']}/{r['name']}`" for r in repos_list] + chunk_size = 20 + for i in range(0, len(repo_names), chunk_size): + chunk = repo_names[i:i + chunk_size] + field_name = "Repositories" if i == 0 else f"Repositories (cont.)" + embed.add_field( + name=field_name, + value="\n".join(chunk), + inline=False + ) + + embed.set_footer(text="Repos are set in GitHub App installation settings. Stats sync daily at midnight UTC.") + await self._safe_followup(interaction, embed, embed=True) + + except Exception as e: + await self._safe_followup(interaction, f"Error fetching repositories: {str(e)}") + print(f"Error in repos command: {e}") + import traceback + traceback.print_exc() + + return repos From fe4a146a70b947922dc2e8bebeafee5ee60c9e18 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 10:46:29 +0700 Subject: [PATCH 07/11] docs: update MAINTAINER.md setup flow to mention admin or owner --- MAINTAINER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index a7e3425..9c9f859 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -16,7 +16,7 @@ This document explains how to manage the environment variables and how to re-ena | Scenario | Steps | Approval needed? | |---|---|---| | **Owner/Admin runs `/setup`** | `/setup` → click link → Install on GitHub → done | No (owner installs directly) | -| **Member runs `/setup`** | `/setup` → click link → "Request" on GitHub → owner approves from GitHub notification → owner runs `/setup` in Discord | Yes (first time only) | +| **Member runs `/setup`** | `/setup` → click link → "Request" on GitHub → owner approves from GitHub notification → an admin or the owner runs `/setup` in Discord | Yes (first time only) | | **Second Discord server, same org** | Anyone runs `/setup` → click link → app already installed → done | No (already installed) | ### Key Points From 328fe0c521c15aba1f2a6e0b87f49ea5f4ed277b Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 23 Feb 2026 11:56:15 +0700 Subject: [PATCH 08/11] fix: restore sync_triggered variable for success page template The variable was accidentally dropped when replacing set_server_config with complete_setup_atomically. The Jinja template uses it to show sync status text. --- discord_bot/src/bot/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index e26fba4..52a28df 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1209,7 +1209,7 @@ def github_app_setup(): ) # Trigger initial sync and Discord notification - trigger_initial_sync(guild_id, github_org, int(installation_id)) + sync_triggered = trigger_initial_sync(guild_id, github_org, int(installation_id)) notify_setup_complete(guild_id, github_org) success_page = """ From b1b0a7d4346332b723e8d01f6d0449d71b387a0e Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 24 Feb 2026 14:26:41 +0700 Subject: [PATCH 09/11] fix: ensure guild members are chunked before assigning roles --- discord_bot/src/services/guild_service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index dadef3b..40ff2eb 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -162,8 +162,13 @@ def resolve_custom_role(rule: Dict[str, Any]): return existing_roles.get(role_name) return None + # Ensure all members are cached before iterating + if not guild.chunked: + await guild.chunk() + # Update users updated_count = 0 + print(f"Guild has {len(guild.members)} members, user_mappings has {len(user_mappings)} entries") for member in guild.members: github_username = user_mappings.get(str(member.id)) if not github_username or github_username not in contributions: From 079c3489f08adf8d0e19d124bd02af368e34c5de Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 25 Feb 2026 15:26:16 +0700 Subject: [PATCH 10/11] fix: fork API waste, PR counting, user mappings, role colors, session handling - github_service: fork auto-detection, author filter, parent repo PR search - contribution_processor: pre-init account owner in contributions - pipeline YAML: case-insensitive username match, auto-repair servers list, single asyncio.run - guild_service: single Discord session for all servers (update_multiple_servers) - user_commands: auto-register server on /getstats - role_service: fix color keys to match thresholds, add Jul 2025 obsolete roles - admin_commands: remove /roles command --- .github/workflows/discord_bot_pipeline.yml | 65 +++++++-- .../src/bot/commands/admin_commands.py | 2 +- discord_bot/src/bot/commands/user_commands.py | 25 ++++ .../processors/contribution_processor.py | 14 ++ discord_bot/src/services/github_service.py | 133 +++++++++++++++--- discord_bot/src/services/guild_service.py | 96 +++++++++---- discord_bot/src/services/role_service.py | 37 +++-- 7 files changed, 294 insertions(+), 78 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 8a49f3f..e6c0e3b 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -317,39 +317,80 @@ jobs: print(f'Found {len(servers)} registered Discord servers') - # Update each Discord server with its organization's data + # Build all server jobs first, then connect to Discord ONCE + server_jobs = [] for discord_server_id, server_config in servers.items(): github_org = server_config.get('github_org') if not github_org or github_org not in all_processed_data: print(f'Skipping server {discord_server_id}: no data for org {github_org}') continue - print(f'Updating Discord server {discord_server_id} with {github_org} data...') + print(f'Preparing Discord server {discord_server_id} with {github_org} data...') org_data = all_processed_data[github_org] contributions = org_data['contributions'] repo_metrics = org_data['repo_metrics'] + # Build case-insensitive lookup for contributions (GitHub usernames are case-insensitive) + contributions_lower = {k.lower(): k for k in contributions} + print(f'Contributions dict has {len(contributions)} GitHub users: {list(contributions.keys())[:20]}') + # Get user mappings for this server's organization - user_mappings_data = mt_client.db.collection('discord_users').stream() + user_mappings_data = list(mt_client.db.collection('discord_users').stream()) user_mappings = {} + auto_repair_count = 0 for doc in user_mappings_data: user_data = doc.to_dict() github_id = user_data.get('github_id') servers_list = user_data.get('servers', []) - # Include user if they're in this server and have contributions in this org - if github_id and discord_server_id in servers_list and github_id in contributions: - user_mappings[doc.id] = github_id + if not github_id: + continue + + # Case-insensitive contribution lookup + canonical_github_id = contributions_lower.get(github_id.lower()) + in_server = discord_server_id in servers_list + in_contributions = canonical_github_id is not None + + print(f' Discord user {doc.id}: github_id={github_id}, servers={servers_list}, in_server={in_server}, in_contributions={in_contributions}') + + if in_contributions and in_server: + user_mappings[doc.id] = canonical_github_id + elif in_contributions and not in_server: + # Auto-repair: user has contributions for this org but their + # servers list is missing this server ID. + print(f' AUTO-REPAIR: Adding server {discord_server_id} to {github_id} servers list (was {servers_list})') + servers_list.append(discord_server_id) + user_data['servers'] = servers_list + try: + mt_client.set_user_mapping(doc.id, user_data) + auto_repair_count += 1 + except Exception as repair_err: + print(f' AUTO-REPAIR FAILED for {doc.id}: {repair_err}') + user_mappings[doc.id] = canonical_github_id + elif not in_contributions: + print(f' SKIP: {github_id} not found in contributions for org {github_org}') + if auto_repair_count: + print(f'Auto-repaired servers list for {auto_repair_count} users') print(f'Found {len(user_mappings)} user mappings for server {discord_server_id}') - # Update Discord roles and channels for this server - import asyncio - success = asyncio.run(guild_service.update_roles_and_channels( - discord_server_id, user_mappings, contributions, repo_metrics - )) - print(f'Discord updates for server {discord_server_id} completed: {success}') + server_jobs.append({ + 'discord_server_id': discord_server_id, + 'user_mappings': user_mappings, + 'contributions': contributions, + 'metrics': repo_metrics, + }) + + # Connect to Discord ONCE and update all servers in a single session + # (avoids unclosed-connector warnings from repeated connect/disconnect) + import asyncio + if server_jobs: + results = asyncio.run(guild_service.update_multiple_servers(server_jobs)) + for discord_server_id, success in results.items(): + print(f'Discord updates for server {discord_server_id} completed: {success}') + else: + print('No server jobs to process') print('All Discord server updates completed!') " diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index d557a0b..f44d2fa 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -270,7 +270,7 @@ async def setup_voice_stats(interaction: discord.Interaction): traceback.print_exc() return setup_voice_stats - + def _add_reviewer_command(self): """Create the add_reviewer command.""" @app_commands.command(name="add_reviewer", description="Add a GitHub username to the PR reviewer pool") diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 13b4aab..3bc844a 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -47,6 +47,27 @@ async def _safe_followup(self, interaction, message, embed=False): if exc.code == 40060: return raise + + async def _ensure_server_registered(self, discord_user_id: str, discord_server_id: str) -> None: + """Ensure the current server is in the user's servers list. + + A user only runs /link once. When they later join a new server and + interact with the bot there, the new server_id is not yet in their + Firestore document. This helper silently adds it so that: + - The pipeline's user-mapping lookup succeeds immediately + - The user gets roles assigned on the next daily run + """ + mt_client = get_mt_client() + user_mapping = await asyncio.to_thread(mt_client.get_user_mapping, discord_user_id) or {} + github_id = user_mapping.get('github_id') + if not github_id: + return # not linked yet — nothing to do + existing_servers = user_mapping.get('servers', []) + if discord_server_id not in existing_servers: + existing_servers.append(discord_server_id) + user_mapping['servers'] = existing_servers + await asyncio.to_thread(mt_client.set_user_mapping, discord_user_id, user_mapping) + print(f"Auto-registered server {discord_server_id} for GitHub user {github_id}") def register_commands(self): """Register all user commands with the bot.""" @@ -362,6 +383,10 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): await self._safe_followup(interaction, "Your Discord account is not linked to a GitHub username. Use `/link` to link it.") return + # Ensure this server is registered in the user's Firestore document + # so the pipeline can assign roles on the next daily run. + await self._ensure_server_registered(user_id, discord_server_id) + github_org = await asyncio.to_thread(mt_client.get_org_from_server, discord_server_id) if not github_org: await self._safe_followup(interaction, "This server is not configured yet. Run `/setup` first.") diff --git a/discord_bot/src/pipeline/processors/contribution_processor.py b/discord_bot/src/pipeline/processors/contribution_processor.py index f412a6f..7c396be 100644 --- a/discord_bot/src/pipeline/processors/contribution_processor.py +++ b/discord_bot/src/pipeline/processors/contribution_processor.py @@ -21,6 +21,15 @@ def process_raw_data(raw_data): all_contributions = {} repositories = raw_data.get('repositories', {}) + # Ensure the account owner (organization or personal account) is always + # present in contributions, even if they have zero activity. This + # prevents the user_mappings lookup from failing for personal accounts + # that only have forked repos with no direct contributions yet. + account_owner = raw_data.get('organization', '') + if account_owner: + _initialize_user_if_needed(account_owner, all_contributions) + print(f"Pre-initialized account owner: {account_owner}") + for repo_name, repo_data in repositories.items(): print(f"Processing repository: {repo_name}") _process_repository(repo_data, all_contributions) @@ -37,6 +46,11 @@ def _process_repository(repo_data, all_contributions): all_usernames = _extract_usernames(contributors, pull_requests, issues, commits) + # Also include the repo owner so they always appear in the contributions + repo_owner = repo_data.get('owner') + if repo_owner: + all_usernames.add(repo_owner) + for username in all_usernames: if not username: continue diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index 2500211..83f6bab 100644 --- a/discord_bot/src/services/github_service.py +++ b/discord_bot/src/services/github_service.py @@ -184,13 +184,19 @@ def _paginate_search_results(self, base_url: str, rate_type: str = 'search') -> 'total_count': max(total_count, len(all_items)) } - def _paginate_list_results(self, base_url: str, rate_type: str = 'core') -> List[Dict[str, Any]]: - """Paginate through all list results (non-search API).""" + def _paginate_list_results(self, base_url: str, rate_type: str = 'core', max_pages: int = None) -> List[Dict[str, Any]]: + """Paginate through all list results (non-search API). + + Args: + base_url: The API URL to paginate. + rate_type: The rate limit bucket ('core' or 'search'). + max_pages: Optional safety cap on number of pages to fetch. + """ all_items = [] page = 1 per_page = 100 - print(f"DEBUG - Starting list pagination for: {base_url}") + print(f"DEBUG - Starting list pagination for: {base_url}" + (f" (max_pages={max_pages})" if max_pages else "")) while True: joiner = "&" if "?" in base_url else "?" @@ -214,6 +220,10 @@ def _paginate_list_results(self, base_url: str, rate_type: str = 'core') -> List print(f"DEBUG - List pagination complete: {len(all_items)} items collected") break + if max_pages and page >= max_pages: + print(f"DEBUG - Reached max pages limit ({max_pages}), stopping pagination with {len(all_items)} items") + break + page += 1 return all_items @@ -307,30 +317,63 @@ def fetch_accessible_repositories(self) -> List[Dict[str, str]]: return repos return self.fetch_organization_repositories() - def search_pull_requests(self, owner: str, repo: str) -> Dict[str, Any]: - """Search for ALL pull requests in a repository with complete pagination.""" - pr_url = f"{self.api_url}/search/issues?q=repo:{owner}/{repo}+type:pr+is:merged" - print(f"DEBUG - Collecting ALL PRs for {owner}/{repo}") + def search_pull_requests(self, owner: str, repo: str, author_filter: str = None) -> Dict[str, Any]: + """Search for pull requests in a repository, optionally filtered by author. + + Args: + owner: Repository owner. + repo: Repository name. + author_filter: If set, only return PRs authored by this GitHub user. + """ + query = f"repo:{owner}/{repo}+type:pr+is:merged" + if author_filter: + query += f"+author:{author_filter}" + pr_url = f"{self.api_url}/search/issues?q={query}" + filter_msg = f" (author: {author_filter})" if author_filter else "" + print(f"DEBUG - Collecting PRs for {owner}/{repo}{filter_msg}") results = self._paginate_search_results(pr_url, 'search') - print(f"DEBUG - Collected {len(results['items'])} PRs for {owner}/{repo}") + print(f"DEBUG - Collected {len(results['items'])} PRs for {owner}/{repo}{filter_msg}") return results - def search_issues(self, owner: str, repo: str) -> Dict[str, Any]: - """Search for ALL issues in a repository with complete pagination.""" - issue_url = f"{self.api_url}/search/issues?q=repo:{owner}/{repo}+type:issue" - print(f"DEBUG - Collecting ALL issues for {owner}/{repo}") + def search_issues(self, owner: str, repo: str, author_filter: str = None) -> Dict[str, Any]: + """Search for issues in a repository, optionally filtered by author. + + Args: + owner: Repository owner. + repo: Repository name. + author_filter: If set, only return issues authored by this GitHub user. + """ + query = f"repo:{owner}/{repo}+type:issue" + if author_filter: + query += f"+author:{author_filter}" + issue_url = f"{self.api_url}/search/issues?q={query}" + filter_msg = f" (author: {author_filter})" if author_filter else "" + print(f"DEBUG - Collecting issues for {owner}/{repo}{filter_msg}") results = self._paginate_search_results(issue_url, 'search') - print(f"DEBUG - Collected {len(results['items'])} issues for {owner}/{repo}") + print(f"DEBUG - Collected {len(results['items'])} issues for {owner}/{repo}{filter_msg}") return results - def search_commits(self, owner: str, repo: str) -> Dict[str, Any]: - """Get ALL commits for a repository using complete pagination.""" + def search_commits(self, owner: str, repo: str, author_filter: str = None) -> Dict[str, Any]: + """Get commits for a repository, optionally filtered by author. + + For forked repositories, use author_filter to avoid paginating through + the entire upstream commit history (which can be tens of thousands of + commits and burn hundreds of API calls). + + Args: + owner: Repository owner. + repo: Repository name. + author_filter: If set, only return commits by this GitHub user. + """ commits_url = f"{self.api_url}/repos/{owner}/{repo}/commits" - print(f"DEBUG - Collecting ALL commits for {owner}/{repo}") + if author_filter: + commits_url += f"?author={author_filter}" + filter_msg = f" (author: {author_filter})" if author_filter else " (all authors)" + print(f"DEBUG - Collecting commits for {owner}/{repo}{filter_msg}") commits_list = self._paginate_list_results(commits_url, 'core') @@ -342,22 +385,68 @@ def search_commits(self, owner: str, repo: str) -> Dict[str, Any]: } def collect_complete_repository_data(self, owner: str, repo: str) -> Dict[str, Any]: - """Collect ALL data for a single repository.""" + """Collect data for a single repository. + + Detects forks automatically. For forked repos: + - Commits are filtered by the fork owner (avoids paginating upstream history). + - PRs/issues are also searched in the parent/upstream repo by the fork owner, + since PRs from forks are merged in the upstream, not the fork itself. + """ print(f"DEBUG - Starting complete data collection for {owner}/{repo}") + repo_info = self.fetch_repository_data(owner, repo) + is_fork = repo_info.get('fork', False) + parent_info = repo_info.get('parent', {}) if is_fork else {} + + if is_fork: + parent_owner = parent_info.get('owner', {}).get('login', '?') + parent_repo = parent_info.get('name', '?') + print(f"DEBUG - {owner}/{repo} is a FORK of {parent_owner}/{parent_repo}") + print(f"DEBUG - Filtering contributions by author: {owner}") + + # For forks: only get the fork owner's commits (not entire upstream history) + author_filter = owner if is_fork else None + repo_data = { 'name': repo, 'owner': owner, - 'repo_info': self.fetch_repository_data(owner, repo), + 'repo_info': repo_info, 'contributors': self.fetch_contributors(owner, repo), - 'pull_requests': self.search_pull_requests(owner, repo), - 'issues': self.search_issues(owner, repo), - 'commits_search': self.search_commits(owner, repo), + 'pull_requests': self.search_pull_requests(owner, repo, author_filter=author_filter), + 'issues': self.search_issues(owner, repo, author_filter=author_filter), + 'commits_search': self.search_commits(owner, repo, author_filter=author_filter), 'labels': self.fetch_repository_labels(owner, repo) } + # For forks: also search the parent/upstream repo for this user's PRs and issues. + # PRs from forks get merged in the upstream repo, so searching only the fork + # will return nearly zero results. + if is_fork and parent_info: + parent_owner = parent_info.get('owner', {}).get('login') + parent_repo = parent_info.get('name') + if parent_owner and parent_repo: + print(f"DEBUG - Searching parent repo {parent_owner}/{parent_repo} for {owner}'s PRs and issues") + + parent_prs = self.search_pull_requests(parent_owner, parent_repo, author_filter=owner) + parent_issues = self.search_issues(parent_owner, parent_repo, author_filter=owner) + + # Merge parent results (deduplicate by ID) + existing_pr_ids = {pr.get('id') for pr in repo_data['pull_requests']['items']} + for pr in parent_prs['items']: + if pr.get('id') not in existing_pr_ids: + repo_data['pull_requests']['items'].append(pr) + repo_data['pull_requests']['total_count'] = len(repo_data['pull_requests']['items']) + + existing_issue_ids = {issue.get('id') for issue in repo_data['issues']['items']} + for issue in parent_issues['items']: + if issue.get('id') not in existing_issue_ids: + repo_data['issues']['items'].append(issue) + repo_data['issues']['total_count'] = len(repo_data['issues']['items']) + + print(f"DEBUG - After merging parent data: {len(repo_data['pull_requests']['items'])} PRs, {len(repo_data['issues']['items'])} issues") + # Log summary of collected data - print(f"DEBUG - Data collection summary for {owner}/{repo}:") + print(f"DEBUG - Data collection summary for {owner}/{repo}" + (" (FORK)" if is_fork else "") + ":") print(f" - Contributors: {len(repo_data['contributors'])}") print(f" - Pull Requests: {repo_data['pull_requests']['total_count']}") print(f" - Issues: {repo_data['issues']['total_count']}") diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index 40ff2eb..39d6dd1 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -19,71 +19,109 @@ def __init__(self, role_service = None): self._role_service = role_service async def update_roles_and_channels(self, discord_server_id: str, user_mappings: Dict[str, str], contributions: Dict[str, Any], metrics: Dict[str, Any]) -> bool: - """Update Discord roles and channels in a single connection session.""" + """Update Discord roles and channels for a single server. + + Delegates to update_multiple_servers() with a single-item list so that + the bot only connects to Discord once per pipeline run instead of opening + a new client session for every server (which causes unclosed-connector + warnings and wastes the connection handshake). + """ + results = await self.update_multiple_servers([ + { + 'discord_server_id': discord_server_id, + 'user_mappings': user_mappings, + 'contributions': contributions, + 'metrics': metrics, + } + ]) + return results.get(discord_server_id, False) + + async def update_multiple_servers( + self, + server_jobs: list, + ) -> Dict[str, bool]: + """Update roles and channels for multiple Discord servers in a SINGLE + bot connection, avoiding repeated connect/disconnect overhead and + preventing unclosed-connector warnings from leftover aiohttp sessions. + + Args: + server_jobs: list of dicts with keys: + discord_server_id, user_mappings, contributions, metrics + Returns: + dict mapping discord_server_id -> success bool + """ intents = discord.Intents.default() intents.message_content = True intents.members = True client = discord.Client(intents=intents) - # Get server's GitHub organization for organization-specific data from shared.firestore import get_mt_client mt_client = get_mt_client() - server_config = mt_client.get_server_config(discord_server_id) - github_org = server_config.get('github_org') if server_config else None - role_rules = server_config.get('role_rules') if server_config else {} - - success = False - + + # Build a quick lookup: server_id -> job + jobs_by_server = {job['discord_server_id']: job for job in server_jobs} + results: Dict[str, bool] = {job['discord_server_id']: False for job in server_jobs} + @client.event async def on_ready(): - nonlocal success try: print(f"Connected as {client.user}") print(f"Discord client connected to {len(client.guilds)} guilds") - + if not client.guilds: print("WARNING: Bot is not connected to any Discord servers") return - + for guild in client.guilds: - if str(guild.id) == discord_server_id: - print(f"Processing guild: {guild.name} (ID: {guild.id})") + server_id = str(guild.id) + if server_id not in jobs_by_server: + print(f"Skipping guild {guild.name} - not the target server") + continue - # Update roles with organization-specific data + job = jobs_by_server[server_id] + server_config = mt_client.get_server_config(server_id) + github_org = server_config.get('github_org') if server_config else None + role_rules = (server_config.get('role_rules') if server_config else {}) or {} + + print(f"Processing guild: {guild.name} (ID: {guild.id})") + try: updated_count = await self._update_roles_for_guild( guild, - user_mappings, - contributions, + job['user_mappings'], + job['contributions'], github_org, - role_rules or {} + role_rules, ) print(f"Updated {updated_count} members in {guild.name}") - # Update channels - await self._update_channels_for_guild(guild, metrics) + await self._update_channels_for_guild(guild, job['metrics']) print(f"Updated channels in {guild.name}") - else: - print(f"Skipping guild {guild.name} - not the target server {discord_server_id}") - - success = True - print("Discord updates completed successfully") - + + results[server_id] = True + print(f"Discord updates completed successfully for {guild.name}") + except Exception as e: + print(f"Error updating guild {guild.name}: {e}") + import traceback + traceback.print_exc() + except Exception as e: print(f"Error in update process: {e}") import traceback traceback.print_exc() - success = False finally: await client.close() - + try: await client.start(self._token) - return success except Exception as e: print(f"Error connecting to Discord: {e}") import traceback traceback.print_exc() - return False + finally: + if not client.is_closed(): + await client.close() + + return results async def _update_roles_for_guild( self, diff --git a/discord_bot/src/services/role_service.py b/discord_bot/src/services/role_service.py index f0b682b..a14b2bc 100644 --- a/discord_bot/src/services/role_service.py +++ b/discord_bot/src/services/role_service.py @@ -56,36 +56,45 @@ def __init__(self): "1+ Bug Hunter", "6+ Bug Hunter", "16+ Bug Hunter", "31+ Bug Hunter", "51+ Bug Hunter", "1+ Commit", "51+ Commit", "101+ Commit", "251+ Commit", "501+ Commit", "PR Champion", "PR Runner-up", "PR Bronze", - # Old emoji versions + # Old emoji versions (no "s" suffix, 1/6/16/31/51 scale) "🌸 1+ PR", "🌺 6+ PR", "🌻 16+ PR", "🌷 31+ PR", "🌹 51+ PR", "🍃 1+ Issue", "🌿 6+ Issue", "🌱 16+ Issue", "🌾 31+ Issue", "🍀 51+ Issue", "🍃 1+ Issue Reporter", "🌿 6+ Issue Reporter", "🌱 16+ Issue Reporter", "🌾 31+ Issue Reporter", "🍀 51+ Issue Reporter", "🍃 1+ Bug Hunter", "🌿 6+ Bug Hunter", "🌱 16+ Bug Hunter", "🌾 31+ Bug Hunter", "🍀 51+ Bug Hunter", - "☁️ 1+ Commit", "🌊 51+ Commit", "🌈 101+ Commit", "🌙 251+ Commit", "⭐ 501+ Commit" + "☁️ 1+ Commit", "🌊 51+ Commit", "🌈 101+ Commit", "🌙 251+ Commit", "⭐ 501+ Commit", + # Jul 25 2025 emoji+plural names (6/16/31/51 scale PRs, 51/101/251/501 scale Commits) + # "🌸 1+ PRs" is still active — only the higher tiers changed + "🌺 6+ PRs", "🌻 16+ PRs", "🌷 31+ PRs", "🌹 51+ PRs", + # "🍃 1+ GitHub Issues Reported" is still active — only the higher tiers changed + "🌿 6+ GitHub Issues Reported", "🌱 16+ GitHub Issues Reported", + "🌾 31+ GitHub Issues Reported", "🍀 51+ GitHub Issues Reported", + # "☁️ 1+ Commits" is still active — only the higher tiers changed + "🌊 51+ Commits", "🌈 101+ Commits", "🌙 251+ Commits", "⭐ 501+ Commits" } # Role Colors (RGB tuples) - Aesthetic pastels + # Keys MUST exactly match the names in pr_thresholds / issue_thresholds / commit_thresholds self.role_colors = { # PR roles - Pink/Rose pastels "🌸 1+ PRs": (255, 182, 193), # Light pink - "🌺 6+ PRs": (255, 160, 180), # Soft rose - "🌻 16+ PRs": (255, 140, 167), # Medium rose - "🌷 31+ PRs": (255, 120, 154), # Deep rose - "🌹 51+ PRs": (255, 100, 141), # Rich rose + "🌺 5+ PRs": (255, 160, 180), # Soft rose + "🌻 10+ PRs": (255, 140, 167), # Medium rose + "🌷 25+ PRs": (255, 120, 154), # Deep rose + "🌹 50+ PRs": (255, 100, 141), # Rich rose # Issue roles - Green pastels "🍃 1+ GitHub Issues Reported": (189, 252, 201), # Soft mint - "🌿 6+ GitHub Issues Reported": (169, 252, 186), # Light mint - "🌱 16+ GitHub Issues Reported": (149, 252, 171), # Medium mint - "🌾 31+ GitHub Issues Reported": (129, 252, 156), # Deep mint - "🍀 51+ GitHub Issues Reported": (109, 252, 141), # Rich mint + "🌿 5+ GitHub Issues Reported": (169, 252, 186), # Light mint + "🌱 10+ GitHub Issues Reported": (149, 252, 171), # Medium mint + "🌾 25+ GitHub Issues Reported": (129, 252, 156), # Deep mint + "🍀 50+ GitHub Issues Reported": (109, 252, 141), # Rich mint # Commit roles - Blue/Purple pastels "☁️ 1+ Commits": (230, 230, 250), # Lavender - "🌊 51+ Commits": (173, 216, 230), # Light blue - "🌈 101+ Commits": (186, 186, 255), # Periwinkle - "🌙 251+ Commits": (221, 160, 221), # Plum - "⭐ 501+ Commits": (200, 140, 255), # Soft purple + "🌊 25+ Commits": (173, 216, 230), # Light blue + "🌈 50+ Commits": (186, 186, 255), # Periwinkle + "🌙 100+ Commits": (221, 160, 221), # Plum + "⭐ 250+ Commits": (200, 140, 255), # Soft purple # Medal roles - Shimmery pastels "✨ PR Champion": (255, 215, 180), # Champagne From cde6920fdb7b60e6d0934379fd3de03c8ace5818 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 25 Feb 2026 16:43:10 +0700 Subject: [PATCH 11/11] fix: make /getstats and /halloffame public, keep /help /link /unlink ephemeral --- discord_bot/src/bot/commands/user_commands.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 3bc844a..31f59b1 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -19,12 +19,12 @@ def __init__(self, bot): self.bot = bot self._active_links: set[str] = set() # Per-user tracking, not global lock - async def _safe_defer(self, interaction): + async def _safe_defer(self, interaction, ephemeral=True): """Safely defer interaction with error handling.""" try: if interaction.response.is_done(): return - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=ephemeral) except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass @@ -33,13 +33,13 @@ async def _safe_defer(self, interaction): return raise - async def _safe_followup(self, interaction, message, embed=False): + async def _safe_followup(self, interaction, message, embed=False, ephemeral=True): """Safely send followup message with error handling.""" try: if embed: - await interaction.followup.send(embed=message, ephemeral=True) + await interaction.followup.send(embed=message, ephemeral=ephemeral) else: - await interaction.followup.send(message, ephemeral=True) + await interaction.followup.send(message, ephemeral=ephemeral) except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass @@ -363,7 +363,7 @@ def _getstats_command(self): ]) async def getstats(interaction: discord.Interaction, type: str = "pr"): try: - await self._safe_defer(interaction) + await self._safe_defer(interaction, ephemeral=False) except Exception: pass @@ -402,7 +402,7 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) if embed: - await self._safe_followup(interaction, embed, embed=True) + await self._safe_followup(interaction, embed, embed=True, ephemeral=False) except Exception as e: print(f"Error in getstats command: {e}") @@ -429,7 +429,7 @@ def _halloffame_command(self): ]) async def halloffame(interaction: discord.Interaction, type: str = "pr", period: str = "all_time"): try: - await self._safe_defer(interaction) + await self._safe_defer(interaction, ephemeral=False) except Exception: pass @@ -448,7 +448,7 @@ async def halloffame(interaction: discord.Interaction, type: str = "pr", period: return embed = self._create_halloffame_embed(top_3, type, period, hall_of_fame_data.get('last_updated')) - await self._safe_followup(interaction, embed, embed=True) + await self._safe_followup(interaction, embed, embed=True, ephemeral=False) except Exception as e: print(f"Error in halloffame command: {e}")