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/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
diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py
index a0915b9..52a28df 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,27 +943,114 @@ 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
+
+ save_ok = mt_client.complete_setup_atomically(guild_id, {
+ '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:
+ # Transaction returned False — a racing request already completed setup
+ discord_url = f"https://discord.com/channels/{guild_id}"
+ return render_status_page(
+ 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)
+
+ 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.",
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"
@@ -1073,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,
@@ -1085,13 +1197,16 @@ 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))
@@ -1300,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 = """
@@ -1487,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(
@@ -1497,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,
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:
diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py
index a9cad97..f44d2fa 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"
@@ -265,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 9481aae..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
@@ -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."""
@@ -55,6 +76,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 +112,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 +177,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
)
@@ -340,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
@@ -360,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.")
@@ -375,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}")
@@ -402,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
@@ -421,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}")
@@ -551,4 +578,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
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 dadef3b..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
+
+ 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 {}
- # Update roles with organization-specific data
+ 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,
@@ -162,8 +200,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:
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
diff --git a/shared/firestore.py b/shared/firestore.py
index 9974c71..781bb9c 100644
--- a/shared/firestore.py
+++ b/shared/firestore.py
@@ -94,6 +94,87 @@ 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 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.