From 4382a2ac88e80f032cc1cb6cc88a489320683b16 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 14 Sep 2025 14:30:04 -0400 Subject: [PATCH 01/30] feat(setup): add initial setup wizard for Discord bot automation --- .../automation/pr_workflow_generator.py | 160 ++++++++ discord_bot/automation/setup_wizard.py | 349 ++++++++++++++++++ discord_bot/automation/workflow_generator.py | 257 +++++++++++++ 3 files changed, 766 insertions(+) create mode 100644 discord_bot/automation/pr_workflow_generator.py create mode 100644 discord_bot/automation/setup_wizard.py create mode 100644 discord_bot/automation/workflow_generator.py diff --git a/discord_bot/automation/pr_workflow_generator.py b/discord_bot/automation/pr_workflow_generator.py new file mode 100644 index 0000000..95d3ab9 --- /dev/null +++ b/discord_bot/automation/pr_workflow_generator.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +PR Review Automation Workflow Generator +Generates workflows specifically for the separate PR review component +""" + +import yaml +from pathlib import Path +from typing import Dict + +class PRAutomationWorkflowGenerator: + def __init__(self, org_name: str): + self.org_name = org_name + self.workflows_dir = Path(".github/workflows") + self.workflows_dir.mkdir(parents=True, exist_ok=True) + + def generate_pr_automation_workflow(self) -> str: + """Generate PR automation workflow for the separate pr_review component""" + workflow = { + 'name': f'{self.org_name} PR Automation', + 'on': { + 'pull_request': { + 'types': ['opened', 'synchronize', 'reopened'] + } + }, + 'jobs': { + 'pr-automation': { + 'runs-on': 'ubuntu-latest', + 'permissions': { + 'contents': 'read', + 'pull-requests': 'write', + 'issues': 'write' + }, + 'steps': [ + { + 'name': 'Checkout code', + 'uses': 'actions/checkout@v4' + }, + { + 'name': 'Set up Python 3.13', + 'uses': 'actions/setup-python@v5', + 'with': { + 'python-version': '3.13' + } + }, + { + 'name': 'Install PR Review dependencies', + 'run': 'pip install -r pr_review/requirements.txt' + }, + { + 'name': 'Set up Google Credentials for PR Review', + 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > pr_review/config/credentials.json' + }, + { + 'name': 'Run PR Automation', + 'env': { + 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}', + 'GOOGLE_APPLICATION_CREDENTIALS': 'pr_review/config/credentials.json', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', + 'PR_NUMBER': '${{ github.event.pull_request.number }}', + 'REPO_NAME': '${{ github.repository }}' + }, + 'run': 'cd pr_review && python main.py' + } + ] + } + } + } + + workflow_file = self.workflows_dir / f'{self.org_name.lower()}-pr-automation.yml' + with open(workflow_file, 'w') as f: + yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) + + return str(workflow_file) + + def generate_pr_labeler_workflow(self) -> str: + """Generate AI-powered PR labeling workflow""" + workflow = { + 'name': f'{self.org_name} AI PR Labeler', + 'on': { + 'pull_request': { + 'types': ['opened', 'reopened', 'edited'] + } + }, + 'jobs': { + 'ai-pr-labeler': { + 'runs-on': 'ubuntu-latest', + 'permissions': { + 'contents': 'read', + 'pull-requests': 'write' + }, + 'steps': [ + { + 'name': 'Checkout code', + 'uses': 'actions/checkout@v4' + }, + { + 'name': 'Set up Python 3.13', + 'uses': 'actions/setup-python@v5', + 'with': { + 'python-version': '3.13' + } + }, + { + 'name': 'Install dependencies', + 'run': 'pip install -r pr_review/requirements.txt' + }, + { + 'name': 'Set up Google Credentials', + 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > pr_review/config/credentials.json' + }, + { + 'name': 'Run AI PR Labeling', + 'env': { + 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}', + 'GOOGLE_APPLICATION_CREDENTIALS': 'pr_review/config/credentials.json', + 'PR_NUMBER': '${{ github.event.pull_request.number }}', + 'REPO_NAME': '${{ github.repository }}' + }, + 'run': 'cd pr_review && python -c "from utils.ai_pr_labeler import AIPRLabeler; labeler = AIPRLabeler(); labeler.process_pr()"' + } + ] + } + } + } + + workflow_file = self.workflows_dir / f'{self.org_name.lower()}-ai-pr-labeler.yml' + with open(workflow_file, 'w') as f: + yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) + + return str(workflow_file) + + def generate_pr_workflows(self) -> Dict[str, str]: + """Generate all PR-related workflow files""" + workflows = { + 'pr_automation': self.generate_pr_automation_workflow(), + 'ai_pr_labeler': self.generate_pr_labeler_workflow() + } + + print(f"Generated {len(workflows)} PR automation workflows:") + for name, path in workflows.items(): + print(f" - {name}: {path}") + + return workflows + +def main(): + import sys + if len(sys.argv) < 2: + print("Usage: python pr_workflow_generator.py ") + sys.exit(1) + + org_name = sys.argv[1] + generator = PRAutomationWorkflowGenerator(org_name) + generator.generate_pr_workflows() + + print(f"\n✅ PR automation workflows generated for {org_name}") + print("Note: These workflows are for the separate pr_review component") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/discord_bot/automation/setup_wizard.py b/discord_bot/automation/setup_wizard.py new file mode 100644 index 0000000..7996ab2 --- /dev/null +++ b/discord_bot/automation/setup_wizard.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Discord Bot Setup Wizard +Automated deployment setup for Discord bot component only +""" + +import os +import sys +import json +import subprocess +import secrets +import string +import requests +from pathlib import Path +from typing import Dict, Optional, Tuple +import tempfile +import base64 + +class Color: + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + +class DiscordBotSetupWizard: + def __init__(self): + self.config = {} + self.discord_bot_root = Path(__file__).parent.parent + self.setup_dir = self.discord_bot_root / "automation" / "generated" + self.setup_dir.mkdir(exist_ok=True) + + def print_header(self): + print(f"{Color.HEADER}{Color.BOLD}") + print("=" * 60) + print(" DISCORD BOT AUTOMATED SETUP WIZARD") + print("=" * 60) + print(f"{Color.ENDC}") + print(f"{Color.CYAN}Deploy Discord Bot with GitHub integration{Color.ENDC}") + print(f"{Color.CYAN}Automated setup in under 5 minutes{Color.ENDC}") + print(f"{Color.WARNING}Note: This deploys ONLY the Discord bot component{Color.ENDC}") + print(f"{Color.WARNING}PR review runs separately via GitHub Actions{Color.ENDC}\n") + + def collect_user_inputs(self) -> Dict[str, str]: + """Collect minimal required information from user""" + print(f"{Color.BOLD}STEP 1: Basic Information{Color.ENDC}") + print("We need just a few details to get started:\n") + + inputs = {} + + # Organization name + inputs['org_name'] = input(f"{Color.BLUE}GitHub organization name: {Color.ENDC}").strip() + if not inputs['org_name']: + print(f"{Color.FAIL}Organization name is required{Color.ENDC}") + sys.exit(1) + + # Discord bot token + print(f"\n{Color.WARNING}You need to create a Discord application first:{Color.ENDC}") + print("1. Go to https://discord.com/developers/applications") + print("2. Click 'New Application' and give it a name") + print("3. Go to 'Bot' tab and copy the token") + inputs['discord_token'] = input(f"\n{Color.BLUE}Discord bot token: {Color.ENDC}").strip() + if not inputs['discord_token']: + print(f"{Color.FAIL}Discord bot token is required{Color.ENDC}") + sys.exit(1) + + # GitHub token + print(f"\n{Color.WARNING}Create a GitHub personal access token:{Color.ENDC}") + print("1. Go to https://github.com/settings/tokens") + print("2. Click 'Generate new token (classic)'") + print("3. Select 'repo' scope") + inputs['github_token'] = input(f"\n{Color.BLUE}GitHub token: {Color.ENDC}").strip() + if not inputs['github_token']: + print(f"{Color.FAIL}GitHub token is required{Color.ENDC}") + sys.exit(1) + + # Google Cloud project (optional - we can create one) + inputs['gcp_project'] = input(f"{Color.BLUE}Google Cloud project ID (leave empty to create new): {Color.ENDC}").strip() + + return inputs + + def setup_google_cloud(self, project_id: Optional[str] = None) -> Tuple[str, str]: + """Setup Google Cloud infrastructure for Discord bot""" + print(f"\n{Color.BOLD}STEP 2: Google Cloud Setup{Color.ENDC}") + + if not project_id: + project_id = f"discord-bot-{secrets.token_hex(8)}" + print(f"Creating new Google Cloud project: {project_id}") + + try: + subprocess.run(["gcloud", "projects", "create", project_id, + "--name=Discord Bot"], check=True, capture_output=True) + print(f"{Color.GREEN}✓ Project created successfully{Color.ENDC}") + except subprocess.CalledProcessError as e: + print(f"{Color.FAIL}Failed to create project: {e}{Color.ENDC}") + sys.exit(1) + else: + print(f"Using existing Google Cloud project: {project_id}") + # Check if project exists + try: + result = subprocess.run(["gcloud", "projects", "describe", project_id], + check=True, capture_output=True, text=True) + print(f"{Color.GREEN}✓ Project exists and accessible{Color.ENDC}") + except subprocess.CalledProcessError: + print(f"{Color.FAIL}Project {project_id} not found or not accessible{Color.ENDC}") + print(f"{Color.WARNING}Make sure you have access and the project exists{Color.ENDC}") + sys.exit(1) + + subprocess.run(["gcloud", "config", "set", "project", project_id], check=True) + + # Enable required APIs for Discord bot only + apis = [ + "run.googleapis.com", + "cloudbuild.googleapis.com", + "firestore.googleapis.com" + ] + + print("Enabling required APIs...") + for api in apis: + try: + subprocess.run(["gcloud", "services", "enable", api], + check=True, capture_output=True) + print(f"{Color.GREEN}✓ {api} enabled{Color.ENDC}") + except subprocess.CalledProcessError: + print(f"{Color.WARNING}Warning: Could not enable {api}{Color.ENDC}") + + # Create Firestore database + print("Setting up Firestore...") + try: + subprocess.run(["gcloud", "firestore", "databases", "create", + "--region=us-central"], check=True, capture_output=True) + print(f"{Color.GREEN}✓ Firestore database created{Color.ENDC}") + except subprocess.CalledProcessError: + print(f"{Color.WARNING}Firestore database may already exist{Color.ENDC}") + + # Create service account and key + service_account = f"discord-bot-sa@{project_id}.iam.gserviceaccount.com" + key_file = self.setup_dir / "service-account-key.json" + + try: + subprocess.run([ + "gcloud", "iam", "service-accounts", "create", "discord-bot-sa", + "--display-name=Discord Bot Service Account" + ], check=True, capture_output=True) + + subprocess.run([ + "gcloud", "projects", "add-iam-policy-binding", project_id, + "--member", f"serviceAccount:{service_account}", + "--role", "roles/datastore.user" + ], check=True, capture_output=True) + + subprocess.run([ + "gcloud", "iam", "service-accounts", "keys", "create", + str(key_file), + "--iam-account", service_account + ], check=True, capture_output=True) + + print(f"{Color.GREEN}✓ Service account created and key downloaded{Color.ENDC}") + + except subprocess.CalledProcessError as e: + print(f"{Color.FAIL}Service account setup failed: {e}{Color.ENDC}") + sys.exit(1) + + return project_id, str(key_file) + + def deploy_discord_bot(self, project_id: str, service_key_path: str) -> str: + """Deploy Discord bot to Cloud Run and return URL""" + print(f"\n{Color.BOLD}STEP 3: Discord Bot Deployment{Color.ENDC}") + + try: + print("Building Discord bot container...") + subprocess.run([ + "gcloud", "builds", "submit", + "--tag", f"gcr.io/{project_id}/discord-bot", + str(self.discord_bot_root) + ], check=True) + + print("Deploying Discord bot to Cloud Run...") + result = subprocess.run([ + "gcloud", "run", "deploy", "discord-bot", + "--image", f"gcr.io/{project_id}/discord-bot", + "--platform", "managed", + "--region", "us-central1", + "--allow-unauthenticated", + "--port", "8080", + "--memory", "1Gi" + ], capture_output=True, text=True, check=True) + + url_result = subprocess.run([ + "gcloud", "run", "services", "describe", "discord-bot", + "--region", "us-central1", + "--format", "value(status.url)" + ], capture_output=True, text=True, check=True) + + service_url = url_result.stdout.strip() + print(f"{Color.GREEN}✓ Discord bot deployed to: {service_url}{Color.ENDC}") + return service_url + + except subprocess.CalledProcessError as e: + print(f"{Color.FAIL}Deployment failed: {e}{Color.ENDC}") + sys.exit(1) + + def setup_github_oauth(self, service_url: str) -> Tuple[str, str]: + """Create GitHub OAuth app for Discord bot authentication""" + print(f"\n{Color.BOLD}STEP 4: GitHub OAuth Setup{Color.ENDC}") + print(f"{Color.WARNING}Manual step required:{Color.ENDC}") + print("1. Go to https://github.com/settings/developers") + print("2. Click 'New OAuth App'") + print("3. Use these settings:") + print(f" - Application name: Discord Bot for {self.config['org_name']}") + print(f" - Homepage URL: {service_url}") + print(f" - Authorization callback URL: {service_url}/auth/callback") + + client_id = input(f"\n{Color.BLUE}OAuth Client ID: {Color.ENDC}").strip() + client_secret = input(f"{Color.BLUE}OAuth Client Secret: {Color.ENDC}").strip() + + if not client_id or not client_secret: + print(f"{Color.FAIL}OAuth credentials are required{Color.ENDC}") + sys.exit(1) + + return client_id, client_secret + + def generate_configuration_files(self, project_id: str, service_url: str, + oauth_client_id: str, oauth_client_secret: str): + """Generate Discord bot configuration files""" + print(f"\n{Color.BOLD}STEP 5: Configuration Generation{Color.ENDC}") + + # Generate .env file for Discord bot + env_content = f"""DISCORD_BOT_TOKEN={self.config['discord_token']} +GITHUB_TOKEN={self.config['github_token']} +GITHUB_CLIENT_ID={oauth_client_id} +GITHUB_CLIENT_SECRET={oauth_client_secret} +REPO_OWNER={self.config['org_name']} +OAUTH_BASE_URL={service_url} +GOOGLE_APPLICATION_CREDENTIALS=/app/config/credentials.json +""" + + env_file = self.discord_bot_root / "config" / ".env" + env_file.write_text(env_content) + print(f"{Color.GREEN}✓ Generated Discord bot .env file{Color.ENDC}") + + # Generate GitHub Actions secrets setup script + secrets_script = f"""#!/bin/bash +# GitHub Repository Secrets Setup for Discord Bot +# Run this in your repository directory + +gh secret set DISCORD_BOT_TOKEN --body "{self.config['discord_token']}" +gh secret set DEV_GH_TOKEN --body "{self.config['github_token']}" +gh secret set GOOGLE_CREDENTIALS_JSON --body "$(cat {self.setup_dir}/service-account-key.json | base64 -w 0)" +gh secret set REPO_OWNER --body "{self.config['org_name']}" +gh secret set CLOUD_RUN_URL --body "{service_url}" +gh secret set GCP_PROJECT_ID --body "{project_id}" + +echo "Discord Bot secrets configured successfully!" +""" + + secrets_file = self.setup_dir / "setup_github_secrets.sh" + secrets_file.write_text(secrets_script) + secrets_file.chmod(0o755) + print(f"{Color.GREEN}✓ Generated GitHub secrets setup script{Color.ENDC}") + + # Generate deployment summary + summary = f""" +DISCORD BOT DEPLOYMENT SUMMARY +============================== + +Component: Discord Bot Only +Project ID: {project_id} +Service URL: {service_url} +Organization: {self.config['org_name']} + +WHAT WAS DEPLOYED: +- Discord bot with GitHub OAuth integration +- Real-time contribution statistics +- Automated role management +- Voice channel metrics display + +WHAT RUNS SEPARATELY: +- PR review automation (runs via GitHub Actions) +- AI-powered labeling (triggered by PR events) +- Reviewer assignment (GitHub Actions workflow) + +NEXT STEPS: +1. Run the GitHub secrets script: ./discord_bot/automation/generated/setup_github_secrets.sh +2. Invite the bot to your Discord server with admin permissions +3. Test the setup with /link command + +FILES CREATED: +- discord_bot/config/.env (Discord bot environment variables) +- discord_bot/automation/generated/setup_github_secrets.sh (GitHub configuration) +- discord_bot/automation/generated/service-account-key.json (Google Cloud credentials) + +SUPPORT: +- Documentation: ../README.md +- Issues: https://github.com/ruxailab/disgitbot/issues +""" + + summary_file = self.setup_dir / "deployment_summary.txt" + summary_file.write_text(summary) + print(f"{Color.GREEN}✓ Generated deployment summary{Color.ENDC}") + + def run_setup(self): + """Execute complete Discord bot setup process""" + try: + self.print_header() + + # Collect inputs + self.config = self.collect_user_inputs() + + # Setup Google Cloud + project_id, key_file = self.setup_google_cloud(self.config.get('gcp_project')) + + # Deploy Discord bot + service_url = self.deploy_discord_bot(project_id, key_file) + + # Setup OAuth + oauth_client_id, oauth_client_secret = self.setup_github_oauth(service_url) + + # Generate config files + self.generate_configuration_files(project_id, service_url, oauth_client_id, oauth_client_secret) + + print(f"\n{Color.GREEN}{Color.BOLD}🎉 DISCORD BOT SETUP COMPLETE! 🎉{Color.ENDC}") + print(f"{Color.CYAN}Your Discord Bot is deployed and ready to use!{Color.ENDC}") + print(f"\nNext: Run {Color.BOLD}./discord_bot/automation/generated/setup_github_secrets.sh{Color.ENDC}") + + except KeyboardInterrupt: + print(f"\n{Color.WARNING}Setup cancelled by user{Color.ENDC}") + sys.exit(1) + except Exception as e: + print(f"\n{Color.FAIL}Setup failed: {e}{Color.ENDC}") + sys.exit(1) + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--help": + print("Discord Bot Setup Wizard") + print("Automated deployment for Discord bot component only") + print("\nUsage: python3 setup_wizard.py") + print("\nRequirements:") + print("- Google Cloud SDK (gcloud) installed and authenticated") + print("- GitHub CLI (gh) installed and authenticated") + print("- Docker installed") + print("\nNote: This deploys ONLY the Discord bot. PR review runs via GitHub Actions.") + sys.exit(0) + + wizard = DiscordBotSetupWizard() + wizard.run_setup() \ No newline at end of file diff --git a/discord_bot/automation/workflow_generator.py b/discord_bot/automation/workflow_generator.py new file mode 100644 index 0000000..8706fc9 --- /dev/null +++ b/discord_bot/automation/workflow_generator.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +GitHub Actions Workflow Generator for Discord Bot +Generates customized workflows focused on Discord bot deployment +""" + +import yaml +from pathlib import Path +from typing import Dict + +class DiscordBotWorkflowGenerator: + def __init__(self, org_name: str, repo_name: str = None): + self.org_name = org_name + self.repo_name = repo_name or "disgitbot" + self.workflows_dir = Path(".github/workflows") + self.workflows_dir.mkdir(parents=True, exist_ok=True) + + def generate_discord_bot_pipeline_workflow(self) -> str: + """Generate the Discord bot data collection pipeline workflow""" + workflow = { + 'name': f'{self.org_name} Discord Bot Pipeline', + 'on': { + 'schedule': [{'cron': '0 0 * * *'}], # Daily at midnight UTC + 'workflow_dispatch': {}, # Manual trigger + 'push': { + 'branches': ['main'], + 'paths': ['discord_bot/**'] + } + }, + 'jobs': { + 'discord-bot-pipeline': { + 'runs-on': 'ubuntu-latest', + 'steps': [ + { + 'name': 'Checkout repository', + 'uses': 'actions/checkout@v4' + }, + { + 'name': 'Set up Python 3.13', + 'uses': 'actions/setup-python@v5', + 'with': { + 'python-version': '3.13', + 'cache': 'pip', + 'cache-dependency-path': 'discord_bot/requirements.txt' + } + }, + { + 'name': 'Install system dependencies', + 'run': 'sudo apt-get update && sudo apt-get install -y libffi-dev libnacl-dev python3-dev build-essential' + }, + { + 'name': 'Install Python dependencies', + 'run': 'python -m pip install --upgrade pip wheel setuptools && pip install -r discord_bot/requirements.txt' + }, + { + 'name': 'Set up Google Credentials', + 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json' + }, + { + 'name': 'Collect GitHub Data', + 'env': { + 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', + 'PYTHONUNBUFFERED': '1' + }, + 'run': 'cd discord_bot && python -m src.services.github_service' + }, + { + 'name': 'Process Contributions', + 'env': { + 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', + 'PYTHONUNBUFFERED': '1' + }, + 'run': 'cd discord_bot && python -m src.pipeline.processors.contribution_processor' + }, + { + 'name': 'Generate Analytics', + 'env': { + 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', + 'PYTHONUNBUFFERED': '1' + }, + 'run': 'cd discord_bot && python -m src.pipeline.processors.analytics_processor' + }, + { + 'name': 'Update Discord Roles', + 'env': { + 'DISCORD_BOT_TOKEN': '${{ secrets.DISCORD_BOT_TOKEN }}', + 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', + 'PYTHONUNBUFFERED': '1' + }, + 'run': 'cd discord_bot && python -m src.services.guild_service' + } + ] + } + } + } + + workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-pipeline.yml' + with open(workflow_file, 'w') as f: + yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) + + return str(workflow_file) + + def generate_discord_bot_deployment_workflow(self) -> str: + """Generate Discord bot Cloud Run deployment workflow""" + workflow = { + 'name': f'{self.org_name} Discord Bot Deploy', + 'on': { + 'push': { + 'branches': ['main'], + 'paths': ['discord_bot/**'] + }, + 'workflow_dispatch': {} + }, + 'jobs': { + 'deploy-discord-bot': { + 'runs-on': 'ubuntu-latest', + 'steps': [ + { + 'name': 'Checkout code', + 'uses': 'actions/checkout@v4' + }, + { + 'name': 'Set up Cloud SDK', + 'uses': 'google-github-actions/setup-gcloud@v2', + 'with': { + 'service_account_key': '${{ secrets.GOOGLE_CREDENTIALS_JSON }}', + 'project_id': '${{ secrets.GCP_PROJECT_ID }}' + } + }, + { + 'name': 'Configure Docker for GCR', + 'run': 'gcloud auth configure-docker' + }, + { + 'name': 'Build and Deploy Discord Bot', + 'env': { + 'DISCORD_BOT_TOKEN': '${{ secrets.DISCORD_BOT_TOKEN }}', + 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', + 'REPO_OWNER': '${{ secrets.REPO_OWNER }}' + }, + 'run': ''' + cd discord_bot + gcloud builds submit --tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/discord-bot + gcloud run deploy discord-bot \\ + --image gcr.io/${{ secrets.GCP_PROJECT_ID }}/discord-bot \\ + --platform managed \\ + --region us-central1 \\ + --allow-unauthenticated \\ + --port 8080 \\ + --memory 1Gi \\ + --set-env-vars DISCORD_BOT_TOKEN="${{ secrets.DISCORD_BOT_TOKEN }}" \\ + --set-env-vars GITHUB_TOKEN="${{ secrets.DEV_GH_TOKEN }}" \\ + --set-env-vars REPO_OWNER="${{ secrets.REPO_OWNER }}" \\ + --set-env-vars OAUTH_BASE_URL="${{ secrets.CLOUD_RUN_URL }}" + ''' + } + ] + } + } + } + + workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-deploy.yml' + with open(workflow_file, 'w') as f: + yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) + + return str(workflow_file) + + def generate_discord_bot_health_check_workflow(self) -> str: + """Generate Discord bot health monitoring workflow""" + workflow = { + 'name': f'{self.org_name} Discord Bot Health Check', + 'on': { + 'schedule': [{'cron': '*/30 * * * *'}], # Every 30 minutes + 'workflow_dispatch': {} + }, + 'jobs': { + 'health-check-discord-bot': { + 'runs-on': 'ubuntu-latest', + 'steps': [ + { + 'name': 'Check Discord Bot Status', + 'run': ''' + response=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.CLOUD_RUN_URL }}) + if [ $response -eq 200 ]; then + echo "✅ Discord Bot is healthy" + else + echo "❌ Discord Bot health check failed (HTTP $response)" + exit 1 + fi + ''' + }, + { + 'name': 'Discord Notification on Failure', + 'if': 'failure()', + 'run': ''' + curl -X POST "${{ secrets.DISCORD_WEBHOOK_URL }}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "content": "🚨 Discord Bot health check failed for ${{ github.repository }}", + "embeds": [{ + "title": "Discord Bot Service Alert", + "description": "The Discord Bot service appears to be down. Please check the Cloud Run logs.", + "color": 15158332, + "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)'" + }] + }' + ''' + } + ] + } + } + } + + workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-health.yml' + with open(workflow_file, 'w') as f: + yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) + + return str(workflow_file) + + def generate_discord_bot_workflows(self) -> Dict[str, str]: + """Generate Discord bot workflow files and return file paths""" + workflows = { + 'discord_bot_pipeline': self.generate_discord_bot_pipeline_workflow(), + 'discord_bot_deployment': self.generate_discord_bot_deployment_workflow(), + 'discord_bot_health_check': self.generate_discord_bot_health_check_workflow() + } + + print(f"Generated {len(workflows)} Discord Bot GitHub Actions workflows:") + for name, path in workflows.items(): + print(f" - {name}: {path}") + + return workflows + +def main(): + import sys + if len(sys.argv) < 2: + print("Usage: python workflow_generator.py [repo_name]") + sys.exit(1) + + org_name = sys.argv[1] + repo_name = sys.argv[2] if len(sys.argv) > 2 else None + + generator = DiscordBotWorkflowGenerator(org_name, repo_name) + generator.generate_discord_bot_workflows() + + print(f"\n✅ Discord Bot workflows generated for {org_name}") + print("Next steps:") + print("1. Commit and push these workflow files") + print("2. Configure the required repository secrets") + print("3. Workflows will run automatically based on their triggers") + +if __name__ == "__main__": + main() \ No newline at end of file From 16d6c5fbd3fa41582c8e8a717638f7fe8f13d9cf Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:09:24 -0400 Subject: [PATCH 02/30] feat: update Discord bot workflows and clean up deprecated scripts - Updated CI/CD Discord notification workflow and bot pipeline for improved automation - Modified issue templates for bug and feature requests to reflect new process - Removed deprecated automation scripts and documentation files - Enhanced environment example and configuration for Discord bot deployment - Refined GitHub and Discord bot authentication modules - Updated README and architecture documentation for clarity - Improved GitHub service, notification, and role modules for maintainability --- .../workflows/cicd-discord-notifications.yml | 4 +- .github/workflows/discord_bot_pipeline.yml | 306 +++++++++++------- 2 files changed, 187 insertions(+), 123 deletions(-) diff --git a/.github/workflows/cicd-discord-notifications.yml b/.github/workflows/cicd-discord-notifications.yml index e08cb0a..bee01cb 100644 --- a/.github/workflows/cicd-discord-notifications.yml +++ b/.github/workflows/cicd-discord-notifications.yml @@ -154,9 +154,9 @@ jobs: branch='${{ steps.params.outputs.branch }}' ) if success: - print('✅ Discord notification sent successfully') + print('Discord notification sent successfully') else: - print('❌ Failed to send Discord notification') + print('Failed to send Discord notification') sys.exit(1) asyncio.run(send_notification()) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 9a40c46..a1b8298 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - setupWizard jobs: discord-bot-pipeline: @@ -43,10 +44,9 @@ jobs: - name: Set up Google Credentials run: echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json - - name: Collect GitHub Data + - name: Collect GitHub Data for Multiple Organizations env: GITHUB_TOKEN: ${{ secrets.DEV_GH_TOKEN }} - REPO_OWNER: ${{ secrets.REPO_OWNER }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }} run: | @@ -55,17 +55,43 @@ jobs: import sys, json sys.path.insert(0, 'src') from services.github_service import GitHubService - print('Collecting GitHub data...') - github_service = GitHubService() - raw_data = github_service.collect_organization_data() - print(f'Collected data for {len(raw_data.get(\"repositories\", {}))} repositories') - print('Saving raw data...') - with open('raw_data.json', 'w') as f: - json.dump(raw_data, f) - print('Raw data saved to raw_data.json') + from shared.firestore import get_mt_client + + print('Getting registered organizations...') + mt_client = get_mt_client() + + # Get all registered Discord servers + import firebase_admin + from firebase_admin import firestore + db = mt_client.db + servers_ref = db.collection('servers') + servers = {doc.id: doc.to_dict() for doc in servers_ref.stream()} + + # Extract unique GitHub organizations + github_orgs = set() + for server_id, server_config in servers.items(): + github_org = server_config.get('github_org') + if github_org: + github_orgs.add(github_org) + + print(f'Found {len(github_orgs)} unique organizations: {github_orgs}') + + # Collect data for each organization + all_org_data = {} + for github_org in github_orgs: + print(f'Collecting data for organization: {github_org}') + github_service = GitHubService(github_org) + raw_data = github_service.collect_organization_data() + all_org_data[github_org] = raw_data + print(f'Collected data for {len(raw_data.get(\"repositories\", {}))} repositories in {github_org}') + + print('Saving all organization data...') + with open('all_org_data.json', 'w') as f: + json.dump(all_org_data, f) + print('All organization data saved to all_org_data.json') " - - name: Process Contributions & Analytics + - name: Process Contributions & Analytics for Multiple Organizations env: PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }} @@ -76,48 +102,54 @@ jobs: sys.path.insert(0, 'src') from pipeline.processors import contribution_functions, analytics_functions, metrics_functions, reviewer_functions - print('Loading raw data...') - with open('raw_data.json', 'r') as f: - raw_data = json.load(f) - - print('Processing contributions...') - contributions = contribution_functions.process_raw_data(raw_data) - contributions = contribution_functions.calculate_rankings(contributions) - contributions = contribution_functions.calculate_streaks_and_averages(contributions) - - print('Creating analytics...') - hall_of_fame = analytics_functions.create_hall_of_fame_data(contributions) - analytics_data = analytics_functions.create_analytics_data(contributions) - - print('Calculating metrics...') - repo_metrics = metrics_functions.create_repo_metrics(raw_data, contributions) - - print('Processing repository labels...') - processed_labels = metrics_functions.process_repository_labels(raw_data) - - print('Generating reviewer pool...') - reviewer_pool = reviewer_functions.generate_reviewer_pool(contributions) - contributor_summary = reviewer_functions.get_contributor_summary(contributions) - - print(f'Processed {len(contributions)} contributors') - print(f'Generated reviewer pool with {reviewer_pool.get(\"count\", 0)} reviewers') - - print('Saving processed data...') - processed_data = { - 'contributions': contributions, - 'hall_of_fame': hall_of_fame, - 'analytics_data': analytics_data, - 'repo_metrics': repo_metrics, - 'processed_labels': processed_labels, - 'reviewer_pool': reviewer_pool, - 'contributor_summary': contributor_summary - } - with open('processed_data.json', 'w') as f: - json.dump(processed_data, f) - print('Processed data saved to processed_data.json') + print('Loading all organization data...') + with open('all_org_data.json', 'r') as f: + all_org_data = json.load(f) + + # Process each organization separately + all_processed_data = {} + for github_org, raw_data in all_org_data.items(): + print(f'Processing organization: {github_org}') + + print('Processing contributions...') + contributions = contribution_functions.process_raw_data(raw_data) + contributions = contribution_functions.calculate_rankings(contributions) + contributions = contribution_functions.calculate_streaks_and_averages(contributions) + + print('Creating analytics...') + hall_of_fame = analytics_functions.create_hall_of_fame_data(contributions) + analytics_data = analytics_functions.create_analytics_data(contributions) + + print('Calculating metrics...') + repo_metrics = metrics_functions.create_repo_metrics(raw_data, contributions) + + print('Processing repository labels...') + processed_labels = metrics_functions.process_repository_labels(raw_data) + + print('Generating reviewer pool...') + reviewer_pool = reviewer_functions.generate_reviewer_pool(contributions) + contributor_summary = reviewer_functions.get_contributor_summary(contributions) + + print(f'Processed {len(contributions)} contributors for {github_org}') + print(f'Generated reviewer pool with {reviewer_pool.get(\"count\", 0)} reviewers for {github_org}') + + all_processed_data[github_org] = { + 'contributions': contributions, + 'hall_of_fame': hall_of_fame, + 'analytics_data': analytics_data, + 'repo_metrics': repo_metrics, + 'processed_labels': processed_labels, + 'reviewer_pool': reviewer_pool, + 'contributor_summary': contributor_summary + } + + print('Saving all processed data...') + with open('all_processed_data.json', 'w') as f: + json.dump(all_processed_data, f) + print('All processed data saved to all_processed_data.json') " - - name: Store Data in Firestore + - name: Store Data in Multi-Tenant Firestore env: GOOGLE_APPLICATION_CREDENTIALS: discord_bot/config/credentials.json PYTHONUNBUFFERED: 1 @@ -125,58 +157,64 @@ jobs: run: | cd discord_bot python -u -c " - from shared.firestore import set_document, query_collection, update_document + from shared.firestore import get_mt_client import json - print('Loading processed data...') - with open('processed_data.json', 'r') as f: - data = json.load(f) - - contributions = data['contributions'] - hall_of_fame = data['hall_of_fame'] - analytics_data = data['analytics_data'] - repo_metrics = data['repo_metrics'] - processed_labels = data['processed_labels'] - reviewer_pool = data['reviewer_pool'] - contributor_summary = data['contributor_summary'] - - print('Storing data in Firestore...') - set_document('repo_stats', 'metrics', repo_metrics) - set_document('repo_stats', 'hall_of_fame', hall_of_fame) - set_document('repo_stats', 'analytics', analytics_data) - - print('Storing reviewer pool...') - set_document('pr_config', 'reviewers', reviewer_pool) - set_document('repo_stats', 'contributor_summary', contributor_summary) - print(f'Stored reviewer pool with {reviewer_pool.get(\"count\", 0)} reviewers') - - print('Storing repository labels...') - labels_stored = 0 - for repo_name, label_data in processed_labels.items(): - doc_id = repo_name.replace('/', '_') - if set_document('repository_labels', doc_id, label_data): - labels_stored += 1 - print(f\"Stored {label_data['count']} labels for {repo_name}\") - - print(f'Stored labels for {labels_stored} repositories') - - user_mappings = query_collection('discord') - stored_count = 0 - - for username, user_data in contributions.items(): - discord_id = None - for uid, data in user_mappings.items(): - if data.get('github_id') == username: - discord_id = uid - break - if discord_id: - if update_document('discord', discord_id, user_data): - stored_count += 1 - - print(f'Stored data for {stored_count} users') + print('Loading all processed data...') + with open('all_processed_data.json', 'r') as f: + all_processed_data = json.load(f) + + mt_client = get_mt_client() + + # Store data for each organization + for github_org, data in all_processed_data.items(): + print(f'Storing data for organization: {github_org}') + + contributions = data['contributions'] + hall_of_fame = data['hall_of_fame'] + analytics_data = data['analytics_data'] + repo_metrics = data['repo_metrics'] + processed_labels = data['processed_labels'] + reviewer_pool = data['reviewer_pool'] + contributor_summary = data['contributor_summary'] + + print(f'Storing repo stats for {github_org}...') + mt_client.set_org_document(github_org, 'repo_stats', 'metrics', repo_metrics) + mt_client.set_org_document(github_org, 'repo_stats', 'hall_of_fame', hall_of_fame) + mt_client.set_org_document(github_org, 'repo_stats', 'analytics', analytics_data) + mt_client.set_org_document(github_org, 'repo_stats', 'contributor_summary', contributor_summary) + + print(f'Storing reviewer pool for {github_org}...') + mt_client.set_org_document(github_org, 'pr_config', 'reviewers', reviewer_pool) + print(f'Stored reviewer pool with {reviewer_pool.get(\"count\", 0)} reviewers for {github_org}') + + print(f'Storing repository labels for {github_org}...') + labels_stored = 0 + for repo_name, label_data in processed_labels.items(): + doc_id = repo_name.replace('/', '_') + if mt_client.set_org_document(github_org, 'repository_labels', doc_id, label_data): + labels_stored += 1 + print(f\"Stored {label_data['count']} labels for {repo_name} in {github_org}\") + + print(f'Stored labels for {labels_stored} repositories in {github_org}') + + # Update user contribution data + user_mappings = mt_client.query_org_collection('users', 'discord') # Get all users + stored_count = 0 + + for username, user_data in contributions.items(): + # Find Discord users with this GitHub username + for discord_id, user_mapping in user_mappings.items(): + if user_mapping.get('github_id') == username: + if mt_client.set_user_mapping(discord_id, {**user_mapping, **user_data}): + stored_count += 1 + + print(f'Updated contribution data for {stored_count} users in {github_org}') + + print('All organization data stored successfully!') " - - name: Update Discord Roles & Channels + - name: Update Discord Roles & Channels for All Servers env: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} GOOGLE_APPLICATION_CREDENTIALS: discord_bot/config/credentials.json @@ -185,37 +223,63 @@ jobs: run: | cd discord_bot python -u -c " - from shared.firestore import query_collection # Uses PYTHONPATH (no path setup needed) - import sys, json # Standard library imports - sys.path.insert(0, 'src') # Setup for local modules - from services.guild_service import GuildService # Uses src/ path - from services.role_service import RoleService # Uses src/ path + from shared.firestore import get_mt_client + import sys, json + sys.path.insert(0, 'src') + from services.guild_service import GuildService + from services.role_service import RoleService - print('Loading processed data...') - with open('processed_data.json', 'r') as f: - data = json.load(f) + print('Loading all processed data...') + with open('all_processed_data.json', 'r') as f: + all_processed_data = json.load(f) - contributions = data['contributions'] - repo_metrics = data['repo_metrics'] + mt_client = get_mt_client() print('Initializing Discord services...') role_service = RoleService() guild_service = GuildService(role_service) - print('Getting user mappings...') - user_mappings_data = query_collection('discord') - user_mappings = {} - for discord_id, data in user_mappings_data.items(): - github_id = data.get('github_id') - if github_id: - user_mappings[discord_id] = github_id + # Get all registered Discord servers + servers_ref = mt_client.db.collection('servers') + servers = {doc.id: doc.to_dict() for doc in servers_ref.stream()} + + print(f'Found {len(servers)} registered Discord servers') - print(f'Found {len(user_mappings)} user mappings') + # Update each Discord server with its organization's data + 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...') + + org_data = all_processed_data[github_org] + contributions = org_data['contributions'] + repo_metrics = org_data['repo_metrics'] + + # Get user mappings for this server's organization + user_mappings_data = mt_client.db.collection('users').stream() + user_mappings = {} + 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 + + 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_server_roles_and_channels( + discord_server_id, user_mappings, contributions, repo_metrics + )) + print(f'Discord updates for server {discord_server_id} completed: {success}') - print('Updating Discord roles and channels...') - import asyncio - success = asyncio.run(guild_service.update_roles_and_channels(user_mappings, contributions, repo_metrics)) - print(f'Discord updates completed: {success}') + print('All Discord server updates completed!') " - name: Pipeline Summary From f9aae68259608267eae973b87347908d1b3c6e58 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:14:43 -0400 Subject: [PATCH 03/30] attempt to fix --- .github/workflows/discord_bot_pipeline.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index a1b8298..1be1350 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -48,12 +48,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.DEV_GH_TOKEN }} PYTHONUNBUFFERED: 1 - PYTHONPATH: ${{ github.workspace }} + PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | cd discord_bot python -u -c " import sys, json sys.path.insert(0, 'src') + sys.path.insert(0, '..') from services.github_service import GitHubService from shared.firestore import get_mt_client From f32c227ee85f3310bc3d82ad06e481bed601a485 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:16:20 -0400 Subject: [PATCH 04/30] fix(pipeline): add debug logging and fix shared module import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive debug logging to identify import issues - Explicitly add parent directory to Python path - Show available functions in shared.firestore module - Fix PYTHONPATH to include both repo root and discord_bot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/ISSUE_TEMPLATE/bug.yml | 22 +- .github/ISSUE_TEMPLATE/feature.yml | 14 +- .github/workflows/discord_bot_pipeline.yml | 33 +- .gitignore | 4 +- METRICS_DOCUMENTATION.md | 227 ----------- blog.md | 306 --------------- discord_bot/ARCHITECTURE.md | 16 +- discord_bot/README.md | 17 +- .../automation/pr_workflow_generator.py | 160 -------- discord_bot/automation/setup_wizard.py | 349 ----------------- discord_bot/automation/workflow_generator.py | 257 ------------- discord_bot/config/.env.example | 3 +- discord_bot/src/bot/auth.py | 352 +++++++++++++++++- discord_bot/src/bot/bot.py | 101 ++++- .../src/bot/commands/admin_commands.py | 61 ++- .../src/bot/commands/analytics_commands.py | 12 +- .../src/bot/commands/notification_commands.py | 4 +- discord_bot/src/bot/commands/user_commands.py | 44 ++- discord_bot/src/services/github_service.py | 4 +- .../src/services/notification_service.py | 10 +- discord_bot/src/services/role_service.py | 4 +- force-sync.sh | 21 -- run_branch_workflows.sh | 16 +- scripts/run_workflows.py | 24 +- shared/firestore.py | 237 +++++++++++- 25 files changed, 888 insertions(+), 1410 deletions(-) delete mode 100644 METRICS_DOCUMENTATION.md delete mode 100644 blog.md delete mode 100644 discord_bot/automation/pr_workflow_generator.py delete mode 100644 discord_bot/automation/setup_wizard.py delete mode 100644 discord_bot/automation/workflow_generator.py delete mode 100755 force-sync.sh diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index d2bc544..9c42cc5 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,12 +1,12 @@ -name: 🐛 Bug Report +name:Bug Report description: Submit a bug report to help us improve -title: '[🐞 BUG]: ' +title: '[BUG]: ' labels: [Bug] body: - type: markdown attributes: value: | - # Welcome to the Bug Report template! 🚀 + # Welcome to the Bug Report template! Please use this template to report any bugs or issues you encounter. Fill in the information below to help us understand and resolve the problem quickly. @@ -18,23 +18,23 @@ body: 4. Describe the expected and actual behavior. 5. Provide details about your environment, including the Vue component or project file affected, Git branch, etc. - Thank you for helping us improve our project! 🙌 + Thank you for helping us improve our project! - type: textarea attributes: - label: Description 📝 + label: Description description: A clear and concise description of what the bug is. validations: required: true - type: input attributes: - label: Link 🔗 + label: Link description: Link to the page where the bug occurred. validations: required: true - type: textarea attributes: - label: Steps to Reproduce 🔄 + label: Steps to Reproduce description: Steps to reproduce the behavior. placeholder: | 1. Go to '...' @@ -45,25 +45,25 @@ body: required: true - type: textarea attributes: - label: Screenshots 📸 + label: Screenshots description: If applicable, add screenshots to help explain the problem. validations: required: true - type: textarea attributes: - label: Expected Behavior 🤔 + label: Expected Behavior description: A clear and concise description of what you expected to happen. validations: required: true - type: textarea attributes: - label: Actual Behavior 😱 + label: Actual Behavior description: A clear and concise description of what actually happened. validations: required: true - type: textarea attributes: - label: Environment 🌍 + label: Environment description: Details about your environment, including the Vue component or project file affected, Git branch, etc. placeholder: | - Vue component: [e.g., `src/components/MyComponent.vue`] diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 0274569..bfd1383 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,12 +1,12 @@ -name: ✨ Feature Request +name:Feature Request description: Suggest a new feature to enhance our project -title: '[✨ FEATURE]: ' +title: '[FEATURE]: ' labels: [Enhancement] body: - type: markdown attributes: value: | - # Welcome to the Feature Request template! 🚀 + # Welcome to the Feature Request template! Please use this template to suggest new features or improvements to enhance our project. Fill in the information below to help us understand and evaluate your feature request. @@ -16,23 +16,23 @@ body: 2. Explain the motivation behind the feature request. 3. Specify the expected behavior. - Thank you for contributing to the growth of our project! 🙌 + Thank you for contributing to the growth of our project! - type: textarea attributes: - label: Feature Description 📝 + label: Feature Description description: A clear and concise description of the new feature. validations: required: true - type: textarea attributes: - label: Motivation 🌟 + label: Motivation description: Explain the motivation behind the feature request. validations: required: true - type: textarea attributes: - label: Expected Behavior 🤔 + label: Expected Behavior description: Specify the expected behavior of the new feature. validations: required: true diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 1be1350..eae9742 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -52,11 +52,38 @@ jobs: run: | cd discord_bot python -u -c " - import sys, json + import sys, json, os + print('Current working directory:', os.getcwd()) + print('Python path:', sys.path) + print('Files in parent directory:', os.listdir('..')) + + # Add parent directory to path + parent_dir = os.path.abspath('..') + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + print('Updated Python path:', sys.path) + print('Shared module path:', os.path.join(parent_dir, 'shared')) + print('Shared firestore exists:', os.path.exists(os.path.join(parent_dir, 'shared', 'firestore.py'))) + + try: + import shared.firestore + print('Successfully imported shared.firestore') + print('Available functions in shared.firestore:', dir(shared.firestore)) + except Exception as e: + print('Error importing shared.firestore:', e) + raise + + try: + from shared.firestore import get_mt_client + print('Successfully imported get_mt_client') + except Exception as e: + print('Error importing get_mt_client:', e) + print('Available functions:', [x for x in dir(shared.firestore) if not x.startswith('_')]) + raise + sys.path.insert(0, 'src') - sys.path.insert(0, '..') from services.github_service import GitHubService - from shared.firestore import get_mt_client print('Getting registered organizations...') mt_client = get_mt_client() diff --git a/.gitignore b/.gitignore index 0a7f3d4..29789ba 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,6 @@ create_test_pr.sh pre_prompt.txt reset.sh force-sync.sh -create_test_pr.sh \ No newline at end of file +create_test_pr.sh +remove_emoji.py +CLAUDE.md \ No newline at end of file diff --git a/METRICS_DOCUMENTATION.md b/METRICS_DOCUMENTATION.md deleted file mode 100644 index 0419e59..0000000 --- a/METRICS_DOCUMENTATION.md +++ /dev/null @@ -1,227 +0,0 @@ -# Metrics Documentation - -## Overview - -This document provides comprehensive information about the metrics system implemented in the PR Review Automation and Discord Bot pipeline. The system tracks various code quality and contribution metrics across GitHub repositories. - -## Current Metrics Implementation - -### 1. PR (Pull Request) Metrics - -**Data Collected:** -- Lines added/deleted -- Files changed -- Functions added -- Cyclomatic complexity increase -- Fan-In and Fan-Out coupling metrics -- Design principles analysis -- Risk level assessment -- Risk factors identification - -**Risk Assessment Algorithm:** -```python -# Risk Level Calculation -risk_score = 0 -risk_factors = [] - -# Large changes -if lines_added > 500: - risk_score += 3 - risk_factors.append("Large addition (>500 lines)") - -if files_changed > 15: - risk_score += 2 - risk_factors.append("Many files changed (>15)") - -# Complexity factors -if functions_added > 10: - risk_score += 2 - risk_factors.append("Many new functions (>10)") - -if cyclomatic_complexity > 50: - risk_score += 3 - risk_factors.append("High complexity increase") - -# Risk levels: LOW (0-2), MEDIUM (3-5), HIGH (6+) -``` - -**Design Principles Analysis:** -- **SOLID Principles Compliance**: Checks for Single Responsibility, Open/Closed, Interface Segregation, Dependency Inversion violations -- **God Classes Detection**: Identifies classes that are too large or have too many responsibilities -- **Long Functions**: Flags functions that exceed recommended length limits -- **Parameter Count**: Detects functions with excessive parameters -- **Tight Coupling**: Identifies direct instantiation and dependency issues -- **Magic Values**: Detects hardcoded numbers and strings that should be constants -- **Design Score**: Overall assessment (EXCELLENT, GOOD, FAIR, POOR) - -**Fan-In and Fan-Out Metrics:** -- **Fan-Out**: Number of dependencies this module has on other modules -- **Fan-In**: Number of modules that depend on this module -- **Coupling Factor**: Fan-Out / (Fan-In + Fan-Out) - measures dependency direction -- **Imports Added**: Count of new import/include statements -- **Exports Added**: Count of new export/public declarations - -**Output Example:** -```json -{ - "lines_added": 245, - "lines_deleted": 12, - "files_changed": 8, - "functions_added": 3, - "cyclomatic_complexity_added": 15, - "fan_out": 8, - "fan_in": 3, - "coupling_factor": 0.73, - "imports_added": 8, - "exports_added": 3, - "design_issues_found": 2, - "design_score": "GOOD", - "high_severity_issues": 0, - "medium_severity_issues": 2, - "low_severity_issues": 0, - "issues": [ - { - "principle": "Single Responsibility Principle", - "description": "Function 'process_data' is too long (65 lines)", - "code_snippet": "def process_data(self, data):\n # Complex processing logic...", - "suggestions": [ - "Break process_data into smaller, focused functions", - "Extract complex logic into separate helper methods" - ], - "severity": "MEDIUM" - } - ], - "risk_level": "MEDIUM", - "risk_factors": ["Large addition (>200 lines)", "Medium coupling (8 dependencies)", "Design issues detected (2)"] -} -``` - -### 2. Contributor Metrics - -**Individual Contributor Tracking:** -- Pull requests (daily, weekly, monthly, all-time) -- GitHub issues reported (daily, weekly, monthly, all-time) -- Commits (daily, weekly, monthly, all-time) -- Activity streaks and averages -- Rankings across all time periods - -**Data Structure:** -```json -{ - "username": "contributor_name", - "stats": { - "pr": { - "daily": 2, - "weekly": 8, - "monthly": 25, - "all_time": 150, - "current_streak": 3, - "longest_streak": 12, - "avg_per_day": 1.2 - }, - "issue": { - "daily": 1, - "weekly": 4, - "monthly": 15, - "all_time": 89 - }, - "commit": { - "daily": 5, - "weekly": 35, - "monthly": 120, - "all_time": 2500 - } - }, - "rankings": { - "pr": 3, - "pr_daily": 1, - "pr_weekly": 2, - "pr_monthly": 3 - } -} -``` - -### 3. Repository Metrics - -**Aggregate Repository Data:** -- Total stars, forks, contributors -- Combined PR, issue, and commit counts -- Repository health indicators -- Label distribution analysis - -**Discord Integration:** -- Automated voice channel updates with live stats -- Channel names display real-time metrics -- Daily pipeline updates - -### 4. Hall of Fame System - -**Leaderboard Categories:** -- Pull Requests (all-time, monthly, weekly, daily) -- GitHub Issues Reported (all-time, monthly, weekly, daily) -- Commits (all-time, monthly, weekly, daily) - -**Medal System:** -- PR Champion, PR Runner-up, PR Bronze roles -- Automatic role assignment based on all-time PR rankings -- Aesthetic themed roles with emojis and pastel colors - -## Configuration - -### 1. Firestore Collections - -**Structure:** -``` -repo_stats/ -├── metrics # Repository aggregate metrics -├── hall_of_fame # Leaderboard data -├── analytics # Processed analytics data -└── contributor_summary # Top contributor rankings - -discord/ -└── {user_id} # Individual user contribution data - -pr_config/ -└── reviewers # PR reviewer pool configuration - -repository_labels/ -└── {repo_name} # Repository-specific label data -``` - -### 2. Pipeline Configuration - -**Data Flow:** -1. **Data Collection**: GitHub API calls for repositories, PRs, issues, commits -2. **Processing**: Raw data → structured contributions → rankings → analytics -3. **Storage**: Firestore collections updated with processed data -4. **Discord Updates**: Roles and channel names updated automatically - -**Update Frequency:** -- Currently: Daily via GitHub Actions -- Configurable: Can be adjusted to any frequency (5-minute intervals supported) - -### 3. Role Configuration - -**Badge System:** -```python -# PR Roles (Flower theme, pink pastels) -"🌸 1+ PRs": 1, -"🌺 6+ PRs": 6, -"🌻 16+ PRs": 16, -"🌷 31+ PRs": 31, -"🌹 51+ PRs": 51 - -# Issue Roles (Plant theme, green pastels) -"🍃 1+ GitHub Issues Reported": 1, -"🌿 6+ GitHub Issues Reported": 6, -"🌱 16+ GitHub Issues Reported": 16, -"🌾 31+ GitHub Issues Reported": 31, -"🍀 51+ GitHub Issues Reported": 51 - -# Commit Roles (Sky theme, blue/purple pastels) -"☁️ 1+ Commits": 1, -"🌊 51+ Commits": 51, -"🌈 101+ Commits": 101, -"🌙 251+ Commits": 251, -"⭐ 501+ Commits": 501 -``` \ No newline at end of file diff --git a/blog.md b/blog.md deleted file mode 100644 index 166c9b2..0000000 --- a/blog.md +++ /dev/null @@ -1,306 +0,0 @@ -# Building Disgitbot: A Discord Bot That Bridges GitHub and Community - -*How we built an intelligent Discord bot that automatically tracks contributions, assigns roles, and manages pull requests using AI* - -## The Vision - -Picture this: you're in a Discord server where your role automatically updates based on your GitHub contributions. When you open a pull request, it gets intelligent labels and reviewers assigned by AI. You can see real-time analytics of your team's development activity right in Discord. - -That's exactly what we built with Disgitbot. - -Discord Bot in Action -The bot responds to user commands with real-time GitHub contribution data - -## What We Built - -Disgitbot is a comprehensive Discord bot that integrates GitHub activity with Discord communities. It's not just another bot—it's a complete workflow automation system that handles everything from contribution tracking to AI-powered code review. - -The project was completed as part of Google Summer of Code 2025, working with Uramaki LAB to create something that would actually make developers' lives easier. - -Data Pipeline Overview -The complete data collection and processing pipeline - -## The Core Architecture - -At its heart, Disgitbot runs on a clean, modular architecture. We built it using dependency injection, design patterns, and single responsibility principles. Each component has one clear job, making the system easy to test, maintain, and extend. - -The bot connects to GitHub's API, processes the data through a custom pipeline, stores everything in Firestore, and then updates Discord automatically. It's like having a personal assistant that never sleeps. - -GitHub Actions Process -GitHub Actions workflow that powers the entire system - -## Six Major Features, One Bot - -### 1. Real-Time Contribution Tracking - -The bot collects data from all your GitHub repositories—every pull request, issue, and commit. It processes this information to calculate rankings, streaks, and activity patterns. - -```mermaid -graph TD - A["GitHub Repositories
(ruxailab org)"] --> B["GitHub Service
GitHubService.py"] - B --> C["Raw Data Collection
• PRs, Issues, Commits
• Contributors, Labels
• Repository Info"] - - C --> D["Data Processing Pipeline
discord_bot_pipeline.yml"] - D --> E["Contribution Processor
contribution_processor.py"] - D --> F["Analytics Processor
analytics_processor.py"] - D --> G["Metrics Processor
metrics_processor.py"] - - E --> H["Processed Contributions
• User stats by time period
• Rankings & streaks
• Activity counts"] - F --> I["Analytics Data
• Hall of fame rankings
• Top contributors
• Activity summaries"] - G --> J["Repository Metrics
• Stars, forks, issues
• PR & commit counts
• Contributor totals"] - - H --> K["Firestore Database
Collections:
• repo_stats/analytics
• repo_stats/hall_of_fame
• discord/{user_id}"] - I --> K - J --> K - - K --> L["Discord Bot Commands
• /show-stats
• /show-top-contributors
• /show-activity-comparison"] - - L --> M["Discord User Interface
• Real-time contribution stats
• Interactive charts
• Leaderboards"] - - style A fill:#e1f5fe - style K fill:#f3e5f5 - style M fill:#e8f5e8 -``` - -Users can run commands like `/show-stats` to see their current contribution levels, or `/show-top-contributors` to view leaderboards. The data updates daily through GitHub Actions, so everything stays current. - -### 2. Automatic Role Management - -This is where it gets interesting. The bot automatically assigns Discord roles based on contribution levels. Make your first pull request? You get the "🌸 1+ PRs" role. Reach 51+ PRs? You become a "🌹 51+ PRs" contributor. - -The system runs every night, recalculating everyone's contributions and updating their roles accordingly. It even assigns special medal roles to the top three contributors. - -Auto Role Update -Automatic role assignment based on GitHub contributions - -```mermaid -graph TD - A["GitHub Actions Trigger
Daily at midnight UTC
discord_bot_pipeline.yml"] --> B["Data Collection
GitHubService.collect_organization_data()"] - - B --> C["Process Contributions
contribution_processor.py
• Calculate rankings
• Determine role levels"] - - C --> D["Role Configuration
RoleService.py
• PR roles: Novice → Expert
• Issue roles: Reporter → Investigator
• Commit roles: Contributor → Architect"] - - D --> E["Store in Firestore
repo_stats/contributor_summary
• User contribution levels
• Medal assignments"] - - E --> F["Discord Guild Service
GuildService.py
update_roles_and_channels()"] - - F --> G["Role Assignment Logic
• Remove outdated roles
• Add new roles based on stats
• Assign medal roles (Champion, Runner-up, Bronze)"] - - G --> H["Discord Server Updates
• Automatic role assignment
• Role hierarchy management
• User permission updates"] - - I["User Mappings
discord/{user_id}
GitHub username mapping"] --> F - - style A fill:#fff3e0 - style E fill:#f3e5f5 - style H fill:#e8f5e8 -``` - -### 3. AI-Powered Pull Request Review - -When someone opens a pull request, the bot automatically analyzes it using Google's Gemini AI. It examines the code changes, predicts appropriate labels, and assigns reviewers from a pool of top contributors. - -The AI looks at the PR title, description, and code diff to understand what the change does. It then matches this against the repository's available labels and assigns them with confidence scores. - -PR Review Automation -AI-powered PR review and automation - -```mermaid -graph TD - A["Pull Request Event
opened/synchronize/reopened"] --> B["GitHub Actions Workflow
pr-automation.yml"] - - B --> C["PR Review System
PRReviewSystem.py
main.py"] - - C --> D["GitHub Client
• Get PR details & diff
• Get PR files
• Fetch repository data"] - - C --> E["Metrics Calculator
• Lines changed
• Files modified
• Complexity analysis"] - - C --> F["AI PR Labeler
• Google Gemini API
• Analyze PR content
• Predict labels"] - - C --> G["Reviewer Assigner
• Load reviewer pool from Firestore
• Random selection (1-2 reviewers)
• Top 8 reviewers based on contributions"] - - H["Firestore Database
• pr_config/reviewers
• repository_labels/{repo}
• repo_stats/contributor_summary"] --> G - H --> F - - F --> I["Label Application
• Apply predicted labels to PR
• Confidence threshold: 0.5+"] - G --> J["Reviewer Assignment
• Request reviewers via GitHub API
• Notify assigned reviewers"] - - I --> K["PR Comment
• Metrics summary
• Applied labels
• Assigned reviewers
• Processing status"] - J --> K - - K --> L["Discord Notification
• PR processing complete
• Summary of actions taken"] - - style A fill:#fff3e0 - style H fill:#f3e5f5 - style L fill:#e8f5e8 -``` - -### 4. Intelligent Labeling System - -The bot doesn't just guess at labels—it learns from your repository's existing label structure. It collects all available labels during the daily pipeline run and stores them in Firestore. When a PR comes in, the AI analyzes the content and matches it against these known labels. - -This ensures consistency across your entire organization. No more manually applying labels or forgetting to categorize PRs properly. - -PR Labeling System -AI-powered automatic label assignment - -```mermaid -graph TD - A["Pull Request Trigger
PR opened/updated"] --> B["GitHub Actions
pr-automation.yml"] - - B --> C["AI PR Labeler
AIPRLabeler.py"] - - C --> D["Load Repository Labels
From Firestore:
repository_labels/{repo_name}"] - - D --> E["AI Analysis
Google Gemini API
• Analyze PR title & body
• Review code diff
• Consider PR metrics"] - - E --> F["Label Classification
• Use prompt template
• Match against available labels
• Generate confidence scores"] - - F --> G["Filter by Confidence
Threshold: 0.5+
Select high-confidence labels"] - - G --> H["Apply Labels to PR
GitHub API:
add_labels_to_pull_request()"] - - I["Daily Pipeline
discord_bot_pipeline.yml"] --> J["Label Collection
process_repository_labels()
• Fetch all repo labels
• Store label metadata"] - - J --> K["Store in Firestore
repository_labels/{repo}
• Label names & descriptions
• Usage statistics"] - - K --> D - - H --> L["Updated PR
• Labels automatically applied
• Consistent labeling across repos
• Reduced manual effort"] - - style A fill:#fff3e0 - style K fill:#f3e5f5 - style L fill:#e8f5e8 -``` - -### 5. Live Repository Metrics - -The bot creates and updates Discord voice channels with real-time repository statistics. You'll see channels like "Stars: 1,234", "Forks: 567", and "Contributors: 89" that update automatically. - -These metrics are aggregated from all your repositories, giving you a bird's-eye view of your organization's GitHub activity. - -Live Metrics -Real-time repository metrics displayed in Discord - -```mermaid -graph TD - A["Daily Pipeline Trigger
GitHub Actions
discord_bot_pipeline.yml"] --> B["Metrics Processor
metrics_processor.py
create_repo_metrics()"] - - B --> C["Aggregate Repository Data
• Total stars & forks
• Total PRs & issues
• Total commits
• Contributor count"] - - C --> D["Store Metrics
Firestore:
repo_stats/metrics"] - - D --> E["Guild Service
GuildService.py
_update_channels_for_guild()"] - - E --> F["Discord Channel Management
• Find/create 'REPOSITORY STATS' category
• Update voice channel names
• Real-time metric display"] - - F --> G["Live Discord Channels
Voice Channels:
• 'Stars: 1,234'
• 'Forks: 567'
• 'Contributors: 89'
• 'PRs: 2,345'
• 'Issues: 678'
• 'Commits: 12,345'"] - - H["Raw GitHub Data
• Repository info
• Contribution data
• API responses"] --> B - - I["Repository Health
• Last updated timestamps
• Data freshness indicators
• Collection status"] --> D - - style A fill:#fff3e0 - style D fill:#f3e5f5 - style G fill:#e8f5e8 -``` - -### 6. Analytics and Hall of Fame - -The bot generates beautiful charts and leaderboards showing contributor activity over time. Users can view top contributors by different metrics, see activity trends, and compare performance across the team. - -The hall of fame system tracks leaders in multiple categories (PRs, issues, commits) across different time periods (daily, weekly, monthly, all-time). - -Analytics Dashboard -Interactive analytics and contributor insights - -Hall of Fame -Top contributors leaderboard - -```mermaid -graph TD - A["Contribution Data
From daily pipeline
User stats & rankings"] --> B["Analytics Processor
analytics_processor.py"] - - B --> C["Hall of Fame Generator
create_hall_of_fame_data()
• Top 10 per category
• Multiple time periods
• PR/Issue/Commit rankings"] - - B --> D["Analytics Data Creator
create_analytics_data()
• Summary statistics
• Top contributors
• Activity trends"] - - C --> E["Hall of Fame Data
Leaderboards by period:
• Daily, Weekly, Monthly, All-time
• Separate rankings for PRs, Issues, Commits"] - - D --> F["Analytics Data
• Total contributor count
• Active contributor metrics
• Top 5 contributors per category
• Activity summaries"] - - E --> G["Firestore Storage
repo_stats/hall_of_fame
repo_stats/analytics"] - F --> G - - G --> H["Discord Commands
• /show-top-contributors
• /show-activity-comparison
• /show-activity-trends
• /show-time-series"] - - H --> I["Chart Generation
chart_generators.py
• TopContributorsChart
• ActivityComparisonChart
• TimeSeriesChart"] - - I --> J["Visual Analytics
Discord Interface:
• Interactive bar charts
• Time series graphs
• Contributor comparisons
• Hall of fame displays"] - - K["Medal System
• PR Champion
• PR Runner-up
• PR Bronze
Auto-assigned roles"] --> G - - style A fill:#e1f5fe - style G fill:#f3e5f5 - style J fill:#e8f5e8 -``` - -## Technical Implementation - -### The Data Pipeline - -Everything runs through a daily GitHub Actions workflow that: -1. Collects raw data from GitHub's API -2. Processes contributions and calculates metrics -3. Stores everything in Firestore -4. Updates Discord roles and channels - -The pipeline is designed to handle rate limits gracefully and can process hundreds of repositories without hitting API limits. - -Data Processing -Data processing and transformation pipeline - -### AI Integration - -We use Google's Gemini API for intelligent analysis. The AI examines code changes, understands context, and makes informed decisions about labeling and review assignments. It's trained on your specific repository structure, so it gets better over time. - -### Discord Integration - -The bot connects to Discord using their official API and manages everything from role assignments to channel updates. It handles authentication, permissions, and user management automatically. - -## Deployment and Cost Optimization - -The bot runs on Google Cloud Run with request-based billing, meaning it only costs money when it's actually processing requests. During idle time, it scales to zero instances, keeping costs minimal. - -We've optimized the deployment process with a comprehensive script that handles everything from environment setup to service deployment. The bot automatically manages its own scaling and resource allocation. - -Cloud Deployment -Cloud deployment and monitoring logs - -## Real-World Impact - -Since deploying Disgitbot, we've seen some real improvements: -- **Faster PR reviews** thanks to automatic labeling and reviewer assignment -- **Increased engagement** as contributors see their progress reflected in real-time -- **Better project visibility** through live metrics and analytics -- **Reduced administrative overhead** as the bot handles routine tasks automatically - -## What's Next - -The project is designed to be extensible. We can easily add new features like: -- Integration with other project management tools -- More sophisticated AI analysis -- Custom analytics dashboards -- Integration with CI/CD pipelines - -## Conclusion - -Disgitbot shows what happens when you combine modern cloud infrastructure, AI capabilities, and thoughtful design. It's not just a bot—it's a complete workflow automation system that makes development teams more productive and engaged. - -The project demonstrates how AI can be used to solve real problems in software development, not just generate code or answer questions. By automating the routine aspects of project management, it frees developers to focus on what they do best: building great software. - -You can try the bot yourself in the [RUXAILAB Discord Server](https://discord.gg/VAxzZxVV), or explore the code on [GitHub](https://github.com/ruxailab/disgitbot). - ---- - -*This project was completed as part of Google Summer of Code 2025 with Uramaki LAB. Special thanks to the mentors and community members who provided guidance and feedback throughout the development process.* diff --git a/discord_bot/ARCHITECTURE.md b/discord_bot/ARCHITECTURE.md index 229d943..ce23a5c 100644 --- a/discord_bot/ARCHITECTURE.md +++ b/discord_bot/ARCHITECTURE.md @@ -61,25 +61,25 @@ discord_bot/src/ ## Design Principles Enforced -### Single Responsibility Principle ✅ +### Single Responsibility Principle - Each class/module has **one clear purpose** - `UserCommands` only handles user interactions - `FirestoreService` only manages database operations - `ContributionProcessor` only processes contribution data -### Open/Closed Principle ✅ +### Open/Closed Principle - **Extensible without modification** - Add new pipeline stages without changing orchestrator - Add new chart types without modifying existing generators - Add new Discord commands without touching existing ones -### Dependency Inversion ✅ +### Dependency Inversion - **Depend on abstractions, not concretions** - Services depend on `IStorageService` interface - Pipeline stages inject dependencies via constructor - Clear interface boundaries -### Interface Segregation ✅ +### Interface Segregation - **Small, focused interfaces** - `IStorageService` only database operations - `IDiscordService` only Discord operations @@ -114,22 +114,22 @@ user_commands.register_commands() ## Benefits Achieved -### 🧪 **Testability** +###**Testability** - **Dependency injection** enables clean testing - **Small, focused methods** are simple to test - **Interface-based design** allows test doubles -### 🔧 **Maintainability** +###**Maintainability** - **Single responsibility** makes changes predictable - **Loose coupling** prevents cascading changes - **Clear interfaces** document expected behavior -### 📈 **Scalability** +###**Scalability** - **Add new pipeline stages** without touching existing code - **Add new Discord commands** via new command modules - **Add new storage backends** by implementing interfaces -### 🔄 **Reusability** +###**Reusability** - **Services can be used independently** across modules - **Processors are composable** and reusable - **Chart generators follow consistent patterns** diff --git a/discord_bot/README.md b/discord_bot/README.md index 08923cc..9a59f72 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -260,6 +260,15 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - **Example:** `OAUTH_BASE_URL=https://discord-bot-abcd1234-uc.a.run.app` - **Add to GitHub Secrets:** Create secret named `CLOUD_RUN_URL` with the same URL +3. **Configure Discord OAuth Redirect URI:** + - Go to [Discord Developer Portal](https://discord.com/developers/applications) + - Select your bot application (same one from Step 1) + - Go to **OAuth2** → **General** + - In the **Redirects** section, click **Add Redirect** + - Add: `YOUR_CLOUD_RUN_URL/setup` + - **Example:** `https://discord-bot-abcd1234-uc.a.run.app/setup` + - Click **Save Changes** + ### Step 5: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) **What this configures:** @@ -409,7 +418,7 @@ python -u main.py 2>&1 | tee -a discord_bot.log ```python def run_discord_bot_async(): """Run the Discord bot asynchronously using existing bot setup""" - print("🤖 Starting Discord bot...") + print("Starting Discord bot...") try: # Import the existing Discord bot with all commands @@ -419,7 +428,7 @@ def run_discord_bot_async(): print(" Discord bot setup imported successfully") # Get the bot instance and run it - print("🤖 Starting Discord bot connection...") + print("Starting Discord bot connection...") discord_bot_module.bot.run(discord_bot_module.TOKEN) ``` @@ -428,12 +437,12 @@ def run_discord_bot_async(): **File: `discord_bot/main.py` (Lines 64-75)** ```python # Start Discord bot in a separate thread -print("🧵 Setting up Discord bot thread...") +print("Setting up Discord bot thread...") def start_discord_bot(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - print("🤖 Starting Discord bot in thread...") + print("Starting Discord bot in thread...") run_discord_bot_async() except Exception as e: print(f" Discord bot error: {e}") diff --git a/discord_bot/automation/pr_workflow_generator.py b/discord_bot/automation/pr_workflow_generator.py deleted file mode 100644 index 95d3ab9..0000000 --- a/discord_bot/automation/pr_workflow_generator.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -PR Review Automation Workflow Generator -Generates workflows specifically for the separate PR review component -""" - -import yaml -from pathlib import Path -from typing import Dict - -class PRAutomationWorkflowGenerator: - def __init__(self, org_name: str): - self.org_name = org_name - self.workflows_dir = Path(".github/workflows") - self.workflows_dir.mkdir(parents=True, exist_ok=True) - - def generate_pr_automation_workflow(self) -> str: - """Generate PR automation workflow for the separate pr_review component""" - workflow = { - 'name': f'{self.org_name} PR Automation', - 'on': { - 'pull_request': { - 'types': ['opened', 'synchronize', 'reopened'] - } - }, - 'jobs': { - 'pr-automation': { - 'runs-on': 'ubuntu-latest', - 'permissions': { - 'contents': 'read', - 'pull-requests': 'write', - 'issues': 'write' - }, - 'steps': [ - { - 'name': 'Checkout code', - 'uses': 'actions/checkout@v4' - }, - { - 'name': 'Set up Python 3.13', - 'uses': 'actions/setup-python@v5', - 'with': { - 'python-version': '3.13' - } - }, - { - 'name': 'Install PR Review dependencies', - 'run': 'pip install -r pr_review/requirements.txt' - }, - { - 'name': 'Set up Google Credentials for PR Review', - 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > pr_review/config/credentials.json' - }, - { - 'name': 'Run PR Automation', - 'env': { - 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}', - 'GOOGLE_APPLICATION_CREDENTIALS': 'pr_review/config/credentials.json', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', - 'PR_NUMBER': '${{ github.event.pull_request.number }}', - 'REPO_NAME': '${{ github.repository }}' - }, - 'run': 'cd pr_review && python main.py' - } - ] - } - } - } - - workflow_file = self.workflows_dir / f'{self.org_name.lower()}-pr-automation.yml' - with open(workflow_file, 'w') as f: - yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) - - return str(workflow_file) - - def generate_pr_labeler_workflow(self) -> str: - """Generate AI-powered PR labeling workflow""" - workflow = { - 'name': f'{self.org_name} AI PR Labeler', - 'on': { - 'pull_request': { - 'types': ['opened', 'reopened', 'edited'] - } - }, - 'jobs': { - 'ai-pr-labeler': { - 'runs-on': 'ubuntu-latest', - 'permissions': { - 'contents': 'read', - 'pull-requests': 'write' - }, - 'steps': [ - { - 'name': 'Checkout code', - 'uses': 'actions/checkout@v4' - }, - { - 'name': 'Set up Python 3.13', - 'uses': 'actions/setup-python@v5', - 'with': { - 'python-version': '3.13' - } - }, - { - 'name': 'Install dependencies', - 'run': 'pip install -r pr_review/requirements.txt' - }, - { - 'name': 'Set up Google Credentials', - 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > pr_review/config/credentials.json' - }, - { - 'name': 'Run AI PR Labeling', - 'env': { - 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}', - 'GOOGLE_APPLICATION_CREDENTIALS': 'pr_review/config/credentials.json', - 'PR_NUMBER': '${{ github.event.pull_request.number }}', - 'REPO_NAME': '${{ github.repository }}' - }, - 'run': 'cd pr_review && python -c "from utils.ai_pr_labeler import AIPRLabeler; labeler = AIPRLabeler(); labeler.process_pr()"' - } - ] - } - } - } - - workflow_file = self.workflows_dir / f'{self.org_name.lower()}-ai-pr-labeler.yml' - with open(workflow_file, 'w') as f: - yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) - - return str(workflow_file) - - def generate_pr_workflows(self) -> Dict[str, str]: - """Generate all PR-related workflow files""" - workflows = { - 'pr_automation': self.generate_pr_automation_workflow(), - 'ai_pr_labeler': self.generate_pr_labeler_workflow() - } - - print(f"Generated {len(workflows)} PR automation workflows:") - for name, path in workflows.items(): - print(f" - {name}: {path}") - - return workflows - -def main(): - import sys - if len(sys.argv) < 2: - print("Usage: python pr_workflow_generator.py ") - sys.exit(1) - - org_name = sys.argv[1] - generator = PRAutomationWorkflowGenerator(org_name) - generator.generate_pr_workflows() - - print(f"\n✅ PR automation workflows generated for {org_name}") - print("Note: These workflows are for the separate pr_review component") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/discord_bot/automation/setup_wizard.py b/discord_bot/automation/setup_wizard.py deleted file mode 100644 index 7996ab2..0000000 --- a/discord_bot/automation/setup_wizard.py +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env python3 -""" -Discord Bot Setup Wizard -Automated deployment setup for Discord bot component only -""" - -import os -import sys -import json -import subprocess -import secrets -import string -import requests -from pathlib import Path -from typing import Dict, Optional, Tuple -import tempfile -import base64 - -class Color: - HEADER = '\033[95m' - BLUE = '\033[94m' - CYAN = '\033[96m' - GREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' - BOLD = '\033[1m' - -class DiscordBotSetupWizard: - def __init__(self): - self.config = {} - self.discord_bot_root = Path(__file__).parent.parent - self.setup_dir = self.discord_bot_root / "automation" / "generated" - self.setup_dir.mkdir(exist_ok=True) - - def print_header(self): - print(f"{Color.HEADER}{Color.BOLD}") - print("=" * 60) - print(" DISCORD BOT AUTOMATED SETUP WIZARD") - print("=" * 60) - print(f"{Color.ENDC}") - print(f"{Color.CYAN}Deploy Discord Bot with GitHub integration{Color.ENDC}") - print(f"{Color.CYAN}Automated setup in under 5 minutes{Color.ENDC}") - print(f"{Color.WARNING}Note: This deploys ONLY the Discord bot component{Color.ENDC}") - print(f"{Color.WARNING}PR review runs separately via GitHub Actions{Color.ENDC}\n") - - def collect_user_inputs(self) -> Dict[str, str]: - """Collect minimal required information from user""" - print(f"{Color.BOLD}STEP 1: Basic Information{Color.ENDC}") - print("We need just a few details to get started:\n") - - inputs = {} - - # Organization name - inputs['org_name'] = input(f"{Color.BLUE}GitHub organization name: {Color.ENDC}").strip() - if not inputs['org_name']: - print(f"{Color.FAIL}Organization name is required{Color.ENDC}") - sys.exit(1) - - # Discord bot token - print(f"\n{Color.WARNING}You need to create a Discord application first:{Color.ENDC}") - print("1. Go to https://discord.com/developers/applications") - print("2. Click 'New Application' and give it a name") - print("3. Go to 'Bot' tab and copy the token") - inputs['discord_token'] = input(f"\n{Color.BLUE}Discord bot token: {Color.ENDC}").strip() - if not inputs['discord_token']: - print(f"{Color.FAIL}Discord bot token is required{Color.ENDC}") - sys.exit(1) - - # GitHub token - print(f"\n{Color.WARNING}Create a GitHub personal access token:{Color.ENDC}") - print("1. Go to https://github.com/settings/tokens") - print("2. Click 'Generate new token (classic)'") - print("3. Select 'repo' scope") - inputs['github_token'] = input(f"\n{Color.BLUE}GitHub token: {Color.ENDC}").strip() - if not inputs['github_token']: - print(f"{Color.FAIL}GitHub token is required{Color.ENDC}") - sys.exit(1) - - # Google Cloud project (optional - we can create one) - inputs['gcp_project'] = input(f"{Color.BLUE}Google Cloud project ID (leave empty to create new): {Color.ENDC}").strip() - - return inputs - - def setup_google_cloud(self, project_id: Optional[str] = None) -> Tuple[str, str]: - """Setup Google Cloud infrastructure for Discord bot""" - print(f"\n{Color.BOLD}STEP 2: Google Cloud Setup{Color.ENDC}") - - if not project_id: - project_id = f"discord-bot-{secrets.token_hex(8)}" - print(f"Creating new Google Cloud project: {project_id}") - - try: - subprocess.run(["gcloud", "projects", "create", project_id, - "--name=Discord Bot"], check=True, capture_output=True) - print(f"{Color.GREEN}✓ Project created successfully{Color.ENDC}") - except subprocess.CalledProcessError as e: - print(f"{Color.FAIL}Failed to create project: {e}{Color.ENDC}") - sys.exit(1) - else: - print(f"Using existing Google Cloud project: {project_id}") - # Check if project exists - try: - result = subprocess.run(["gcloud", "projects", "describe", project_id], - check=True, capture_output=True, text=True) - print(f"{Color.GREEN}✓ Project exists and accessible{Color.ENDC}") - except subprocess.CalledProcessError: - print(f"{Color.FAIL}Project {project_id} not found or not accessible{Color.ENDC}") - print(f"{Color.WARNING}Make sure you have access and the project exists{Color.ENDC}") - sys.exit(1) - - subprocess.run(["gcloud", "config", "set", "project", project_id], check=True) - - # Enable required APIs for Discord bot only - apis = [ - "run.googleapis.com", - "cloudbuild.googleapis.com", - "firestore.googleapis.com" - ] - - print("Enabling required APIs...") - for api in apis: - try: - subprocess.run(["gcloud", "services", "enable", api], - check=True, capture_output=True) - print(f"{Color.GREEN}✓ {api} enabled{Color.ENDC}") - except subprocess.CalledProcessError: - print(f"{Color.WARNING}Warning: Could not enable {api}{Color.ENDC}") - - # Create Firestore database - print("Setting up Firestore...") - try: - subprocess.run(["gcloud", "firestore", "databases", "create", - "--region=us-central"], check=True, capture_output=True) - print(f"{Color.GREEN}✓ Firestore database created{Color.ENDC}") - except subprocess.CalledProcessError: - print(f"{Color.WARNING}Firestore database may already exist{Color.ENDC}") - - # Create service account and key - service_account = f"discord-bot-sa@{project_id}.iam.gserviceaccount.com" - key_file = self.setup_dir / "service-account-key.json" - - try: - subprocess.run([ - "gcloud", "iam", "service-accounts", "create", "discord-bot-sa", - "--display-name=Discord Bot Service Account" - ], check=True, capture_output=True) - - subprocess.run([ - "gcloud", "projects", "add-iam-policy-binding", project_id, - "--member", f"serviceAccount:{service_account}", - "--role", "roles/datastore.user" - ], check=True, capture_output=True) - - subprocess.run([ - "gcloud", "iam", "service-accounts", "keys", "create", - str(key_file), - "--iam-account", service_account - ], check=True, capture_output=True) - - print(f"{Color.GREEN}✓ Service account created and key downloaded{Color.ENDC}") - - except subprocess.CalledProcessError as e: - print(f"{Color.FAIL}Service account setup failed: {e}{Color.ENDC}") - sys.exit(1) - - return project_id, str(key_file) - - def deploy_discord_bot(self, project_id: str, service_key_path: str) -> str: - """Deploy Discord bot to Cloud Run and return URL""" - print(f"\n{Color.BOLD}STEP 3: Discord Bot Deployment{Color.ENDC}") - - try: - print("Building Discord bot container...") - subprocess.run([ - "gcloud", "builds", "submit", - "--tag", f"gcr.io/{project_id}/discord-bot", - str(self.discord_bot_root) - ], check=True) - - print("Deploying Discord bot to Cloud Run...") - result = subprocess.run([ - "gcloud", "run", "deploy", "discord-bot", - "--image", f"gcr.io/{project_id}/discord-bot", - "--platform", "managed", - "--region", "us-central1", - "--allow-unauthenticated", - "--port", "8080", - "--memory", "1Gi" - ], capture_output=True, text=True, check=True) - - url_result = subprocess.run([ - "gcloud", "run", "services", "describe", "discord-bot", - "--region", "us-central1", - "--format", "value(status.url)" - ], capture_output=True, text=True, check=True) - - service_url = url_result.stdout.strip() - print(f"{Color.GREEN}✓ Discord bot deployed to: {service_url}{Color.ENDC}") - return service_url - - except subprocess.CalledProcessError as e: - print(f"{Color.FAIL}Deployment failed: {e}{Color.ENDC}") - sys.exit(1) - - def setup_github_oauth(self, service_url: str) -> Tuple[str, str]: - """Create GitHub OAuth app for Discord bot authentication""" - print(f"\n{Color.BOLD}STEP 4: GitHub OAuth Setup{Color.ENDC}") - print(f"{Color.WARNING}Manual step required:{Color.ENDC}") - print("1. Go to https://github.com/settings/developers") - print("2. Click 'New OAuth App'") - print("3. Use these settings:") - print(f" - Application name: Discord Bot for {self.config['org_name']}") - print(f" - Homepage URL: {service_url}") - print(f" - Authorization callback URL: {service_url}/auth/callback") - - client_id = input(f"\n{Color.BLUE}OAuth Client ID: {Color.ENDC}").strip() - client_secret = input(f"{Color.BLUE}OAuth Client Secret: {Color.ENDC}").strip() - - if not client_id or not client_secret: - print(f"{Color.FAIL}OAuth credentials are required{Color.ENDC}") - sys.exit(1) - - return client_id, client_secret - - def generate_configuration_files(self, project_id: str, service_url: str, - oauth_client_id: str, oauth_client_secret: str): - """Generate Discord bot configuration files""" - print(f"\n{Color.BOLD}STEP 5: Configuration Generation{Color.ENDC}") - - # Generate .env file for Discord bot - env_content = f"""DISCORD_BOT_TOKEN={self.config['discord_token']} -GITHUB_TOKEN={self.config['github_token']} -GITHUB_CLIENT_ID={oauth_client_id} -GITHUB_CLIENT_SECRET={oauth_client_secret} -REPO_OWNER={self.config['org_name']} -OAUTH_BASE_URL={service_url} -GOOGLE_APPLICATION_CREDENTIALS=/app/config/credentials.json -""" - - env_file = self.discord_bot_root / "config" / ".env" - env_file.write_text(env_content) - print(f"{Color.GREEN}✓ Generated Discord bot .env file{Color.ENDC}") - - # Generate GitHub Actions secrets setup script - secrets_script = f"""#!/bin/bash -# GitHub Repository Secrets Setup for Discord Bot -# Run this in your repository directory - -gh secret set DISCORD_BOT_TOKEN --body "{self.config['discord_token']}" -gh secret set DEV_GH_TOKEN --body "{self.config['github_token']}" -gh secret set GOOGLE_CREDENTIALS_JSON --body "$(cat {self.setup_dir}/service-account-key.json | base64 -w 0)" -gh secret set REPO_OWNER --body "{self.config['org_name']}" -gh secret set CLOUD_RUN_URL --body "{service_url}" -gh secret set GCP_PROJECT_ID --body "{project_id}" - -echo "Discord Bot secrets configured successfully!" -""" - - secrets_file = self.setup_dir / "setup_github_secrets.sh" - secrets_file.write_text(secrets_script) - secrets_file.chmod(0o755) - print(f"{Color.GREEN}✓ Generated GitHub secrets setup script{Color.ENDC}") - - # Generate deployment summary - summary = f""" -DISCORD BOT DEPLOYMENT SUMMARY -============================== - -Component: Discord Bot Only -Project ID: {project_id} -Service URL: {service_url} -Organization: {self.config['org_name']} - -WHAT WAS DEPLOYED: -- Discord bot with GitHub OAuth integration -- Real-time contribution statistics -- Automated role management -- Voice channel metrics display - -WHAT RUNS SEPARATELY: -- PR review automation (runs via GitHub Actions) -- AI-powered labeling (triggered by PR events) -- Reviewer assignment (GitHub Actions workflow) - -NEXT STEPS: -1. Run the GitHub secrets script: ./discord_bot/automation/generated/setup_github_secrets.sh -2. Invite the bot to your Discord server with admin permissions -3. Test the setup with /link command - -FILES CREATED: -- discord_bot/config/.env (Discord bot environment variables) -- discord_bot/automation/generated/setup_github_secrets.sh (GitHub configuration) -- discord_bot/automation/generated/service-account-key.json (Google Cloud credentials) - -SUPPORT: -- Documentation: ../README.md -- Issues: https://github.com/ruxailab/disgitbot/issues -""" - - summary_file = self.setup_dir / "deployment_summary.txt" - summary_file.write_text(summary) - print(f"{Color.GREEN}✓ Generated deployment summary{Color.ENDC}") - - def run_setup(self): - """Execute complete Discord bot setup process""" - try: - self.print_header() - - # Collect inputs - self.config = self.collect_user_inputs() - - # Setup Google Cloud - project_id, key_file = self.setup_google_cloud(self.config.get('gcp_project')) - - # Deploy Discord bot - service_url = self.deploy_discord_bot(project_id, key_file) - - # Setup OAuth - oauth_client_id, oauth_client_secret = self.setup_github_oauth(service_url) - - # Generate config files - self.generate_configuration_files(project_id, service_url, oauth_client_id, oauth_client_secret) - - print(f"\n{Color.GREEN}{Color.BOLD}🎉 DISCORD BOT SETUP COMPLETE! 🎉{Color.ENDC}") - print(f"{Color.CYAN}Your Discord Bot is deployed and ready to use!{Color.ENDC}") - print(f"\nNext: Run {Color.BOLD}./discord_bot/automation/generated/setup_github_secrets.sh{Color.ENDC}") - - except KeyboardInterrupt: - print(f"\n{Color.WARNING}Setup cancelled by user{Color.ENDC}") - sys.exit(1) - except Exception as e: - print(f"\n{Color.FAIL}Setup failed: {e}{Color.ENDC}") - sys.exit(1) - -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "--help": - print("Discord Bot Setup Wizard") - print("Automated deployment for Discord bot component only") - print("\nUsage: python3 setup_wizard.py") - print("\nRequirements:") - print("- Google Cloud SDK (gcloud) installed and authenticated") - print("- GitHub CLI (gh) installed and authenticated") - print("- Docker installed") - print("\nNote: This deploys ONLY the Discord bot. PR review runs via GitHub Actions.") - sys.exit(0) - - wizard = DiscordBotSetupWizard() - wizard.run_setup() \ No newline at end of file diff --git a/discord_bot/automation/workflow_generator.py b/discord_bot/automation/workflow_generator.py deleted file mode 100644 index 8706fc9..0000000 --- a/discord_bot/automation/workflow_generator.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -""" -GitHub Actions Workflow Generator for Discord Bot -Generates customized workflows focused on Discord bot deployment -""" - -import yaml -from pathlib import Path -from typing import Dict - -class DiscordBotWorkflowGenerator: - def __init__(self, org_name: str, repo_name: str = None): - self.org_name = org_name - self.repo_name = repo_name or "disgitbot" - self.workflows_dir = Path(".github/workflows") - self.workflows_dir.mkdir(parents=True, exist_ok=True) - - def generate_discord_bot_pipeline_workflow(self) -> str: - """Generate the Discord bot data collection pipeline workflow""" - workflow = { - 'name': f'{self.org_name} Discord Bot Pipeline', - 'on': { - 'schedule': [{'cron': '0 0 * * *'}], # Daily at midnight UTC - 'workflow_dispatch': {}, # Manual trigger - 'push': { - 'branches': ['main'], - 'paths': ['discord_bot/**'] - } - }, - 'jobs': { - 'discord-bot-pipeline': { - 'runs-on': 'ubuntu-latest', - 'steps': [ - { - 'name': 'Checkout repository', - 'uses': 'actions/checkout@v4' - }, - { - 'name': 'Set up Python 3.13', - 'uses': 'actions/setup-python@v5', - 'with': { - 'python-version': '3.13', - 'cache': 'pip', - 'cache-dependency-path': 'discord_bot/requirements.txt' - } - }, - { - 'name': 'Install system dependencies', - 'run': 'sudo apt-get update && sudo apt-get install -y libffi-dev libnacl-dev python3-dev build-essential' - }, - { - 'name': 'Install Python dependencies', - 'run': 'python -m pip install --upgrade pip wheel setuptools && pip install -r discord_bot/requirements.txt' - }, - { - 'name': 'Set up Google Credentials', - 'run': 'echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json' - }, - { - 'name': 'Collect GitHub Data', - 'env': { - 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', - 'PYTHONUNBUFFERED': '1' - }, - 'run': 'cd discord_bot && python -m src.services.github_service' - }, - { - 'name': 'Process Contributions', - 'env': { - 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', - 'PYTHONUNBUFFERED': '1' - }, - 'run': 'cd discord_bot && python -m src.pipeline.processors.contribution_processor' - }, - { - 'name': 'Generate Analytics', - 'env': { - 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', - 'PYTHONUNBUFFERED': '1' - }, - 'run': 'cd discord_bot && python -m src.pipeline.processors.analytics_processor' - }, - { - 'name': 'Update Discord Roles', - 'env': { - 'DISCORD_BOT_TOKEN': '${{ secrets.DISCORD_BOT_TOKEN }}', - 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}', - 'PYTHONUNBUFFERED': '1' - }, - 'run': 'cd discord_bot && python -m src.services.guild_service' - } - ] - } - } - } - - workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-pipeline.yml' - with open(workflow_file, 'w') as f: - yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) - - return str(workflow_file) - - def generate_discord_bot_deployment_workflow(self) -> str: - """Generate Discord bot Cloud Run deployment workflow""" - workflow = { - 'name': f'{self.org_name} Discord Bot Deploy', - 'on': { - 'push': { - 'branches': ['main'], - 'paths': ['discord_bot/**'] - }, - 'workflow_dispatch': {} - }, - 'jobs': { - 'deploy-discord-bot': { - 'runs-on': 'ubuntu-latest', - 'steps': [ - { - 'name': 'Checkout code', - 'uses': 'actions/checkout@v4' - }, - { - 'name': 'Set up Cloud SDK', - 'uses': 'google-github-actions/setup-gcloud@v2', - 'with': { - 'service_account_key': '${{ secrets.GOOGLE_CREDENTIALS_JSON }}', - 'project_id': '${{ secrets.GCP_PROJECT_ID }}' - } - }, - { - 'name': 'Configure Docker for GCR', - 'run': 'gcloud auth configure-docker' - }, - { - 'name': 'Build and Deploy Discord Bot', - 'env': { - 'DISCORD_BOT_TOKEN': '${{ secrets.DISCORD_BOT_TOKEN }}', - 'GITHUB_TOKEN': '${{ secrets.DEV_GH_TOKEN }}', - 'REPO_OWNER': '${{ secrets.REPO_OWNER }}' - }, - 'run': ''' - cd discord_bot - gcloud builds submit --tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/discord-bot - gcloud run deploy discord-bot \\ - --image gcr.io/${{ secrets.GCP_PROJECT_ID }}/discord-bot \\ - --platform managed \\ - --region us-central1 \\ - --allow-unauthenticated \\ - --port 8080 \\ - --memory 1Gi \\ - --set-env-vars DISCORD_BOT_TOKEN="${{ secrets.DISCORD_BOT_TOKEN }}" \\ - --set-env-vars GITHUB_TOKEN="${{ secrets.DEV_GH_TOKEN }}" \\ - --set-env-vars REPO_OWNER="${{ secrets.REPO_OWNER }}" \\ - --set-env-vars OAUTH_BASE_URL="${{ secrets.CLOUD_RUN_URL }}" - ''' - } - ] - } - } - } - - workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-deploy.yml' - with open(workflow_file, 'w') as f: - yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) - - return str(workflow_file) - - def generate_discord_bot_health_check_workflow(self) -> str: - """Generate Discord bot health monitoring workflow""" - workflow = { - 'name': f'{self.org_name} Discord Bot Health Check', - 'on': { - 'schedule': [{'cron': '*/30 * * * *'}], # Every 30 minutes - 'workflow_dispatch': {} - }, - 'jobs': { - 'health-check-discord-bot': { - 'runs-on': 'ubuntu-latest', - 'steps': [ - { - 'name': 'Check Discord Bot Status', - 'run': ''' - response=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.CLOUD_RUN_URL }}) - if [ $response -eq 200 ]; then - echo "✅ Discord Bot is healthy" - else - echo "❌ Discord Bot health check failed (HTTP $response)" - exit 1 - fi - ''' - }, - { - 'name': 'Discord Notification on Failure', - 'if': 'failure()', - 'run': ''' - curl -X POST "${{ secrets.DISCORD_WEBHOOK_URL }}" \\ - -H "Content-Type: application/json" \\ - -d '{ - "content": "🚨 Discord Bot health check failed for ${{ github.repository }}", - "embeds": [{ - "title": "Discord Bot Service Alert", - "description": "The Discord Bot service appears to be down. Please check the Cloud Run logs.", - "color": 15158332, - "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)'" - }] - }' - ''' - } - ] - } - } - } - - workflow_file = self.workflows_dir / f'{self.org_name.lower()}-discord-bot-health.yml' - with open(workflow_file, 'w') as f: - yaml.dump(workflow, f, default_flow_style=False, sort_keys=False) - - return str(workflow_file) - - def generate_discord_bot_workflows(self) -> Dict[str, str]: - """Generate Discord bot workflow files and return file paths""" - workflows = { - 'discord_bot_pipeline': self.generate_discord_bot_pipeline_workflow(), - 'discord_bot_deployment': self.generate_discord_bot_deployment_workflow(), - 'discord_bot_health_check': self.generate_discord_bot_health_check_workflow() - } - - print(f"Generated {len(workflows)} Discord Bot GitHub Actions workflows:") - for name, path in workflows.items(): - print(f" - {name}: {path}") - - return workflows - -def main(): - import sys - if len(sys.argv) < 2: - print("Usage: python workflow_generator.py [repo_name]") - sys.exit(1) - - org_name = sys.argv[1] - repo_name = sys.argv[2] if len(sys.argv) > 2 else None - - generator = DiscordBotWorkflowGenerator(org_name, repo_name) - generator.generate_discord_bot_workflows() - - print(f"\n✅ Discord Bot workflows generated for {org_name}") - print("Next steps:") - print("1. Commit and push these workflow files") - print("2. Configure the required repository secrets") - print("3. Workflows will run automatically based on their triggers") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index e06e02d..28ee8c6 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -3,4 +3,5 @@ GITHUB_TOKEN= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= REPO_OWNER= -OAUTH_BASE_URL= \ No newline at end of file +OAUTH_BASE_URL= +DISCORD_BOT_CLIENT_ID= \ No newline at end of file diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 0106a22..fa7f243 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -41,9 +41,153 @@ def create_oauth_app(): @app.route("/") def index(): return jsonify({ - "service": "Discord Bot with OAuth", - "status": "Ready" + "service": "DisgitBot - GitHub Discord Integration", + "status": "Ready", + "endpoints": { + "invite_bot": "/invite", + "setup": "/setup", + "github_auth": "/auth/start/" + } }) + + @app.route("/debug/servers") + def debug_servers(): + """Debug endpoint to see registered servers""" + try: + from shared.firestore import get_mt_client + + mt_client = get_mt_client() + + # Get all servers + servers_ref = mt_client.db.collection('servers') + servers = [] + + for doc in servers_ref.stream(): + server_data = doc.to_dict() + servers.append({ + 'server_id': doc.id, + 'data': server_data + }) + + return jsonify({ + "total_servers": len(servers), + "servers": servers + }) + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/invite") + def invite_bot(): + """Discord bot invitation endpoint""" + from flask import render_template_string + + # Your bot's client ID from Discord Developer Portal + bot_client_id = os.getenv("DISCORD_BOT_CLIENT_ID", "YOUR_BOT_CLIENT_ID") + + # Required permissions for the bot + # Updated permissions to match working invite link + permissions = "552172899344" # Manage Roles + View Channels + Send Messages + Use Slash Commands + + discord_invite_url = ( + f"https://discord.com/oauth2/authorize?" + f"client_id={bot_client_id}&" + f"permissions={permissions}&" + f"integration_type=0&" + f"scope=bot+applications.commands&" + f"redirect_uri={base_url}/setup" + ) + + # Enhanced landing page with clear instructions + landing_page = f""" + + + + Add DisgitBot to Discord + + + + + +
+

Add DisgitBot to Discord

+

Track GitHub contributions and manage roles automatically in your Discord server.

+ +
+ ⚠️ Important: Setup Required After Adding Bot +
+ + Add Bot to Discord + +
+

🔧 Setup Instructions (Required)

+
+ Step 1: Click "Add Bot to Discord" above +
+
+ Step 2: After adding the bot, visit this setup URL: +
{base_url}/setup
+
+
+ Step 3: Enter your GitHub organization name (e.g. "your-org") +
+
+ Step 4: Users can link GitHub accounts with /link in Discord +
+
+ +

Features:

+
+ 📊 Real-time GitHub statistics +
+
+ 🏆 Automated role assignment +
+
+ 📈 Contribution analytics & charts +
+
+ 🔄 Auto-updating voice channels +
+ +

+ Compatible with any GitHub organization. Setup takes 30 seconds. +

+
+ + + """ + + return render_template_string(landing_page, discord_invite_url=discord_invite_url) @app.route("/auth/start/") def start_oauth(discord_user_id): @@ -140,6 +284,175 @@ def github_callback(): print(f"Error in OAuth callback: {e}") return f"Authentication failed: {str(e)}", 500 + @app.route("/setup") + def setup(): + """Setup page after Discord bot is added to server""" + from flask import request, render_template_string + + # Get Discord server info from OAuth callback + guild_id = request.args.get('guild_id') + guild_name = request.args.get('guild_name', 'your server') + + if not guild_id: + return "Error: No Discord server information received", 400 + + setup_page = """ + + + + DisgitBot Setup + + + + + +
+

DisgitBot Added Successfully!

+

Bot has been added to {{ guild_name }}

+ +
+ + +
+ + +
+ Enter the GitHub organization name you want to track.
+ This is the name that appears in GitHub URLs: github.com/your-org/repo-name +
+
+ + +
+ +

+ After setup, users can link their GitHub accounts using /link in Discord. +

+
+ + + """ + + return render_template_string(setup_page, guild_id=guild_id, guild_name=guild_name) + + @app.route("/complete_setup", methods=["POST"]) + def complete_setup(): + """Complete the setup process""" + from flask import request, render_template_string + from shared.firestore import get_mt_client + from datetime import datetime + + guild_id = request.form.get('guild_id') + github_org = request.form.get('github_org', '').strip() + + if not guild_id or not github_org: + return "Error: Missing required information", 400 + + # Validate GitHub organization name (basic validation) + if not github_org.replace('-', '').replace('_', '').isalnum(): + return "Error: Invalid GitHub organization name", 400 + + try: + # Store server configuration + mt_client = get_mt_client() + success = mt_client.set_server_config(guild_id, { + 'github_org': github_org, + 'created_at': datetime.now().isoformat(), + 'setup_completed': True + }) + + if not success: + return "Error: Failed to save configuration", 500 + + # Trigger initial data collection for this organization + try: + trigger_data_pipeline_for_org(github_org) + except Exception as e: + print(f"Warning: Failed to trigger initial data collection: {e}") + # Don't fail setup if pipeline trigger fails + + success_page = """ + + + + Setup Complete! + + + + + +
+

Setup Complete!

+

DisgitBot is now configured to track {{ github_org }} repositories.

+ +

Next Steps:

+

1. Return to Discord

+

2. Users can link their GitHub accounts with:

+
/link
+ +

3. Try these commands:

+
/getstats
+
/halloffame
+ +

+ Data collection will begin shortly. Stats will be available within 5-10 minutes. +

+
+ + + """ + + return render_template_string(success_page, github_org=github_org) + + except Exception as e: + print(f"Error in complete_setup: {e}") + return f"Error: Setup failed - {str(e)}", 500 + return app def get_github_username_for_user(discord_user_id): @@ -180,3 +493,38 @@ def wait_for_username(discord_user_id, max_wait_time=300): del oauth_sessions[discord_user_id] return None + +def trigger_data_pipeline_for_org(github_org): + """Trigger the GitHub Actions workflow to collect data for a specific organization.""" + import requests + + # GitHub API endpoint for triggering workflow_dispatch + repo_owner = os.getenv('REPO_OWNER', 'ruxailab') + repo_name = "disgitbot" + workflow_id = "discord_bot_pipeline.yml" + + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/{workflow_id}/dispatches" + + headers = { + "Authorization": f"token {os.getenv('GITHUB_TOKEN')}", + "Accept": "application/vnd.github.v3+json" + } + + payload = { + "ref": "main", + "inputs": { + "organization": github_org + } + } + + try: + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 204: + print(f"Successfully triggered data pipeline for {github_org}") + return True + else: + print(f"Failed to trigger pipeline for {github_org}. Status: {response.status_code}") + return False + except Exception as e: + print(f"Error triggering pipeline for {github_org}: {e}") + return False diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index d810060..7d78382 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -41,6 +41,7 @@ def _create_bot(self): """Create Discord bot instance.""" intents = discord.Intents.default() intents.message_content = True + intents.guilds = True # Required for on_guild_join event self.bot = commands.Bot(command_prefix="!", intents=intents) @self.bot.event @@ -48,8 +49,106 @@ async def on_ready(): try: synced = await self.bot.tree.sync() print(f"{self.bot.user} is online! Synced {len(synced)} command(s).") + + # Check for any unconfigured servers and notify them + await self._check_server_configurations() + except Exception as e: - print(f"Failed to sync commands: {e}") + print(f"Error in on_ready: {e}") + import traceback + traceback.print_exc() + + @self.bot.event + async def on_guild_join(guild): + """Called when bot joins a new server - provide setup guidance.""" + try: + # Check if server is already configured + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) + + if not server_config: + # Server not configured - send setup message to system channel + system_channel = guild.system_channel + if not system_channel: + # Fallback: find first available text channel + 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") + setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + + setup_message = f"""🎉 **DisgitBot Added Successfully!** + +This server needs to be configured to track GitHub contributions. + +**Quick Setup (30 seconds):** +1. Visit: {setup_url} +2. Enter your GitHub organization name +3. Use `/link` in Discord to connect GitHub accounts + +**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.*""" + + await system_channel.send(setup_message) + print(f"Sent setup guidance to server: {guild.name} (ID: {guild.id})") + + except Exception as e: + print(f"Error sending setup guidance for guild {guild.id}: {e}") + import traceback + traceback.print_exc() + + def _check_server_configurations(self): + """Check for any unconfigured servers and notify them.""" + try: + from shared.firestore import get_mt_client + import asyncio + + async def notify_unconfigured_servers(): + mt_client = get_mt_client() + + for guild in self.bot.guilds: + server_config = mt_client.get_server_config(str(guild.id)) + + if not server_config: + # Server not configured + system_channel = guild.system_channel + if not system_channel: + 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") + setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + + setup_message = f"""⚠️ **DisgitBot Setup Required** + +This server needs to be configured to track GitHub contributions. + +**Quick Setup (30 seconds):** +1. Visit: {setup_url} +2. Enter your GitHub organization name +3. Use `/link` in Discord to connect GitHub accounts + +**Or use this command:** `/setup` + +*This is a one-time setup message.*""" + + await system_channel.send(setup_message) + print(f"Sent setup reminder to server: {guild.name} (ID: {guild.id})") + + # Run the async function + asyncio.create_task(notify_unconfigured_servers()) + + except Exception as e: + print(f"Error checking server configurations: {e}") + import traceback + traceback.print_exc() def _register_commands(self): """Register all command modules.""" diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 030a4cd..fcb403d 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -17,6 +17,7 @@ def __init__(self, bot): def register_commands(self): """Register all admin commands with the bot.""" self.bot.tree.add_command(self._check_permissions_command()) + self.bot.tree.add_command(self._setup_command()) self.bot.tree.add_command(self._setup_voice_stats_command()) self.bot.tree.add_command(self._add_reviewer_command()) self.bot.tree.add_command(self._remove_reviewer_command()) @@ -49,6 +50,55 @@ async def check_permissions(interaction: discord.Interaction): await interaction.followup.send(f"Bot permissions:\n" + "\n".join(results), ephemeral=True) return check_permissions + + def _setup_command(self): + """Create the setup command for server configuration.""" + @app_commands.command(name="setup", description="Get setup link to configure GitHub organization") + async def setup(interaction: discord.Interaction): + """Provides setup link for server administrators.""" + 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" + + # Get the base URL from environment + import os + base_url = os.getenv("OAUTH_BASE_URL") + if not base_url: + await interaction.followup.send("Bot configuration error - please contact support.", ephemeral=True) + return + + setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + + setup_message = f"""**🔧 DisgitBot Setup Required** + +Your server needs to be configured to track a GitHub organization. + +**Steps:** +1. Visit: {setup_url} +2. Enter your GitHub organization name (e.g. "your-org") +3. Users can then link accounts with `/link` + +**Current Status:** ❌ Not configured +**After Setup:** ✅ Ready to track contributions + +This setup is required only once per server.""" + + await interaction.followup.send(setup_message, ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"Error generating setup link: {str(e)}", ephemeral=True) + print(f"Error in setup command: {e}") + import traceback + traceback.print_exc() + + return setup def _setup_voice_stats_command(self): """Create the setup_voice_stats command.""" @@ -85,7 +135,8 @@ async def add_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration - reviewer_data = get_document('pr_config', 'reviewers') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) if not reviewer_data: reviewer_data = {'reviewers': [], 'manual_reviewers': [], 'top_contributor_reviewers': [], 'count': 0} @@ -131,7 +182,8 @@ async def remove_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration - reviewer_data = get_document('pr_config', 'reviewers') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) if not reviewer_data or not reviewer_data.get('reviewers'): await interaction.followup.send("No reviewers found in the database.") return @@ -183,8 +235,9 @@ async def list_reviewers(interaction: discord.Interaction): try: # Get reviewer data - reviewer_data = get_document('pr_config', 'reviewers') - contributor_data = get_document('repo_stats', 'contributor_summary') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) + contributor_data = get_document('repo_stats', 'contributor_summary', discord_server_id) embed = discord.Embed( title="PR Reviewer Pool Status", diff --git a/discord_bot/src/bot/commands/analytics_commands.py b/discord_bot/src/bot/commands/analytics_commands.py index bc3731c..8b668b9 100644 --- a/discord_bot/src/bot/commands/analytics_commands.py +++ b/discord_bot/src/bot/commands/analytics_commands.py @@ -29,7 +29,8 @@ async def show_top_contributors(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -57,7 +58,8 @@ async def show_activity_comparison(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -85,7 +87,8 @@ async def show_activity_trends(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -130,7 +133,8 @@ async def show_time_series(interaction: discord.Interaction, metrics: str = "prs await interaction.followup.send("Invalid metrics. Use: prs, issues, commits, total", ephemeral=True) return - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 7d384b1..5a80d5d 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -203,7 +203,7 @@ async def webhook_status(interaction: discord.Interaction): # Check PR automation webhook pr_webhook = webhook_config.get('pr_automation_webhook_url') if webhook_config else None - pr_status = "✅ Configured" if pr_webhook else "❌ Not configured" + pr_status = "Configured" if pr_webhook else "Not configured" embed.add_field( name="PR Automation Notifications", value=pr_status, @@ -212,7 +212,7 @@ async def webhook_status(interaction: discord.Interaction): # Check CI/CD webhook cicd_webhook = webhook_config.get('cicd_webhook_url') if webhook_config else None - cicd_status = "✅ Configured" if cicd_webhook else "❌ Not configured" + cicd_status = "Configured" if cicd_webhook else "Not configured" embed.add_field( name="CI/CD Notifications", value=cicd_status, diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 6e8f57e..56edaaa 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -47,13 +47,31 @@ async def link(interaction: discord.Interaction): ) if github_username: - set_document('discord', discord_user_id, { + discord_server_id = str(interaction.guild.id) + + # Get existing user data or create new + from shared.firestore import get_mt_client + mt_client = get_mt_client() + existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} + + # Add this server to user's server list + servers_list = existing_user_data.get('servers', []) + if discord_server_id not in servers_list: + servers_list.append(discord_server_id) + + # Update user mapping with server association + user_data = { 'github_id': github_username, - 'pr_count': 0, - 'issues_count': 0, - 'commits_count': 0, - 'role': 'member' - }) + 'servers': servers_list, + 'pr_count': existing_user_data.get('pr_count', 0), + 'issues_count': existing_user_data.get('issues_count', 0), + 'commits_count': existing_user_data.get('commits_count', 0), + 'role': existing_user_data.get('role', 'member'), + 'last_linked_server': discord_server_id, + 'last_updated': str(interaction.created_at) + } + + mt_client.set_user_mapping(discord_user_id, user_data) # Trigger the data pipeline to collect stats for the new user await self._trigger_data_pipeline() @@ -77,11 +95,13 @@ async def unlink(interaction: discord.Interaction): try: await interaction.response.defer(ephemeral=True) - user_data = get_document('discord', str(interaction.user.id)) + discord_server_id = str(interaction.guild.id) + user_data = get_document('discord', str(interaction.user.id), discord_server_id) if user_data: # Delete document by setting it to empty (Firestore will remove it) - set_document('discord', str(interaction.user.id), {}) + discord_server_id = str(interaction.guild.id) + set_document('discord', str(interaction.user.id), {}, discord_server_id=discord_server_id) await interaction.followup.send( "Successfully unlinked your Discord account from your GitHub username.", ephemeral=True @@ -119,7 +139,8 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): user_id = str(interaction.user.id) # Get user's Discord data to find their GitHub username - discord_user_data = get_document('discord', user_id) + discord_server_id = str(interaction.guild.id) + discord_user_data = get_document('discord', user_id, discord_server_id) if not discord_user_data or not discord_user_data.get('github_id'): await interaction.followup.send( "Your Discord account is not linked to a GitHub username. Use `/link` to link it.", @@ -149,7 +170,7 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): print(f"Error in getstats command: {e}") import traceback traceback.print_exc() - await interaction.followup.send("📊 Unable to retrieve your stats. This might be because you just linked your account and your data isn't populated yet. Please try again in a few minutes!", ephemeral=True) + await interaction.followup.send("Unable to retrieve your stats. This might be because you just linked your account and your data isn't populated yet. Please try again in a few minutes!", ephemeral=True) return getstats @@ -171,7 +192,8 @@ def _halloffame_command(self): async def halloffame(interaction: discord.Interaction, type: str = "pr", period: str = "all_time"): await interaction.response.defer() - hall_of_fame_data = get_document('repo_stats', 'hall_of_fame') + discord_server_id = str(interaction.guild.id) + hall_of_fame_data = get_document('repo_stats', 'hall_of_fame', discord_server_id) if not hall_of_fame_data: await interaction.followup.send("Hall of fame data not available yet.", ephemeral=True) diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index f55323f..453b2ca 100644 --- a/discord_bot/src/services/github_service.py +++ b/discord_bot/src/services/github_service.py @@ -13,10 +13,10 @@ class GitHubService: """GitHub API service for data collection.""" - def __init__(self): + def __init__(self, repo_owner: str = None): self.api_url = "https://api.github.com" self.token = os.getenv('GITHUB_TOKEN') - self.repo_owner = os.getenv('REPO_OWNER', 'ruxailab') + self.repo_owner = repo_owner or os.getenv('REPO_OWNER', 'ruxailab') if not self.token: raise ValueError("GITHUB_TOKEN environment variable is required") diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index 96dd30d..f309c7f 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -180,13 +180,13 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, """Build Discord embed for CI/CD notification.""" # Status-based configuration status_config = { - 'success': {'color': 0x28a745, 'emoji': '✅', 'title': 'Workflow Completed'}, - 'failure': {'color': 0xdc3545, 'emoji': '❌', 'title': 'Workflow Failed'}, - 'in_progress': {'color': 0xffc107, 'emoji': '🔄', 'title': 'Workflow Running'}, - 'cancelled': {'color': 0x6c757d, 'emoji': '⏹️', 'title': 'Workflow Cancelled'} + 'success': {'color': 0x28a745, 'emoji': '', 'title': 'Workflow Completed'}, + 'failure': {'color': 0xdc3545, 'emoji': '', 'title': 'Workflow Failed'}, + 'in_progress': {'color': 0xffc107, 'emoji': '', 'title': 'Workflow Running'}, + 'cancelled': {'color': 0x6c757d, 'emoji': '️', 'title': 'Workflow Cancelled'} } - config = status_config.get(status, {'color': 0x6c757d, 'emoji': '❓', 'title': 'Workflow Status'}) + config = status_config.get(status, {'color': 0x6c757d, 'emoji': '', 'title': 'Workflow Status'}) embed = { "title": f"{config['emoji']} {config['title']}", diff --git a/discord_bot/src/services/role_service.py b/discord_bot/src/services/role_service.py index 7698f00..ab43410 100644 --- a/discord_bot/src/services/role_service.py +++ b/discord_bot/src/services/role_service.py @@ -148,10 +148,10 @@ def get_role_color(self, role_name: str) -> Optional[Tuple[int, int, int]]: """Get RGB color for a specific role.""" return self.config.role_colors.get(role_name) - def get_hall_of_fame_data(self) -> Optional[Dict[str, Any]]: + def get_hall_of_fame_data(self, discord_server_id: str) -> Optional[Dict[str, Any]]: """Get hall of fame data from storage.""" from shared.firestore import get_document - return get_document('repo_stats', 'hall_of_fame') + return get_document('repo_stats', 'hall_of_fame', discord_server_id) def get_next_role(self, current_role: str, stats_type: str) -> str: """Determine the next role based on current role and stats type.""" diff --git a/force-sync.sh b/force-sync.sh deleted file mode 100755 index 6a8e3d2..0000000 --- a/force-sync.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# Exit immediately if any command fails -set -e - -echo "Switching to main branch..." -git checkout main - -echo "Resetting local changes..." -git reset --hard - -echo "Removing untracked files and directories..." -git clean -fd - -echo "Fetching latest from origin..." -git fetch origin - -echo "Hard resetting to origin/main..." -git reset --hard origin/main - -echo "Your main branch is now clean and synced with origin/main." diff --git a/run_branch_workflows.sh b/run_branch_workflows.sh index de74256..c3f29b4 100755 --- a/run_branch_workflows.sh +++ b/run_branch_workflows.sh @@ -14,15 +14,15 @@ run_workflow() { local workflow_name="$2" echo "" - echo "🚀 Triggering: $workflow_name" + echo "Triggering: $workflow_name" echo " File: $workflow_file" echo " Branch: $CURRENT_BRANCH" # Try to run the workflow if gh workflow run "$workflow_file" --ref "$CURRENT_BRANCH"; then - echo "✅ Successfully triggered: $workflow_name" + echo "Successfully triggered: $workflow_name" else - echo "❌ Failed to trigger: $workflow_name" + echo "Failed to trigger: $workflow_name" return 1 fi } @@ -34,17 +34,17 @@ echo "============================================================" # Check if GitHub CLI is available if ! command -v gh &> /dev/null; then - echo "❌ GitHub CLI (gh) not found. Install from: https://cli.github.com/" + echo "GitHub CLI (gh) not found. Install from: https://cli.github.com/" exit 1 fi # Check if authenticated if ! gh auth status &> /dev/null; then - echo "❌ GitHub CLI not authenticated. Run: gh auth login" + echo "GitHub CLI not authenticated. Run: gh auth login" exit 1 fi -echo "✅ GitHub CLI is ready" +echo "GitHub CLI is ready" # Run all workflows echo "" @@ -61,8 +61,8 @@ echo "============================================================" echo "All workflows triggered on branch: $CURRENT_BRANCH" echo "============================================================" echo "" -echo "💡 Check workflow status:" +echo "Check workflow status:" echo " gh run list --branch $CURRENT_BRANCH" echo "" -echo "💡 Watch workflow logs:" +echo "Watch workflow logs:" echo " gh run watch" \ No newline at end of file diff --git a/scripts/run_workflows.py b/scripts/run_workflows.py index 9c000ac..2a54cef 100755 --- a/scripts/run_workflows.py +++ b/scripts/run_workflows.py @@ -66,7 +66,7 @@ def list_workflows(self): return for i, workflow in enumerate(self.workflows, 1): - manual_trigger = "✅" if workflow['has_workflow_dispatch'] else "❌" + manual_trigger = "" if workflow['has_workflow_dispatch'] else "" print(f"{i}. {workflow['name']}") print(f" File: {workflow['file']}") print(f" Manual trigger: {manual_trigger}") @@ -92,16 +92,16 @@ def run_workflow(self, workflow_name_or_index: str) -> bool: 'gh', 'workflow', 'run', workflow['name'] ], capture_output=True, text=True, check=True) - print(f"✅ Successfully triggered: {workflow['name']}") + print(f"Successfully triggered: {workflow['name']}") print(f"Output: {result.stdout}") return True except subprocess.CalledProcessError as e: - print(f"❌ Failed to trigger workflow: {e}") + print(f"Failed to trigger workflow: {e}") print(f"Error: {e.stderr}") return False except FileNotFoundError: - print("❌ GitHub CLI (gh) not found. Please install it first:") + print("GitHub CLI (gh) not found. Please install it first:") print("https://cli.github.com/") return False @@ -119,7 +119,7 @@ def run_all_workflows(self) -> Dict[str, bool]: return results for workflow in manual_workflows: - print(f"\n🚀 Triggering: {workflow['name']}") + print(f"\nTriggering: {workflow['name']}") success = self.run_workflow(workflow['name']) results[workflow['name']] = success @@ -129,7 +129,7 @@ def run_all_workflows(self) -> Dict[str, bool]: print("="*60) for name, success in results.items(): - status = "✅ SUCCESS" if success else "❌ FAILED" + status = "SUCCESS" if success else "FAILED" print(f"{status}: {name}") return results @@ -159,27 +159,27 @@ def check_prerequisites(self) -> bool: # Check if we're in a git repository if not Path('.git').exists(): - print("❌ Not in a git repository") + print("Not in a git repository") return False # Check if GitHub CLI is installed try: subprocess.run(['gh', '--version'], capture_output=True, check=True) - print("✅ GitHub CLI is installed") + print("GitHub CLI is installed") except (subprocess.CalledProcessError, FileNotFoundError): - print("❌ GitHub CLI not found. Install from: https://cli.github.com/") + print("GitHub CLI not found. Install from: https://cli.github.com/") return False # Check if authenticated with GitHub try: result = subprocess.run(['gh', 'auth', 'status'], capture_output=True, text=True) if result.returncode == 0: - print("✅ GitHub CLI is authenticated") + print("GitHub CLI is authenticated") else: - print("❌ GitHub CLI not authenticated. Run: gh auth login") + print("GitHub CLI not authenticated. Run: gh auth login") return False except Exception: - print("❌ Could not check GitHub CLI authentication") + print("Could not check GitHub CLI authentication") return False return True diff --git a/shared/firestore.py b/shared/firestore.py index b701430..2586855 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -1,10 +1,99 @@ import os -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional import firebase_admin from firebase_admin import credentials, firestore _db = None +class FirestoreMultiTenant: + """Multi-tenant Firestore client that organizes data by Discord server and GitHub organization.""" + + def __init__(self): + self.db = _get_firestore_client() + + def get_server_config(self, discord_server_id: str) -> Optional[Dict[str, Any]]: + """Get Discord server configuration including GitHub org mapping.""" + try: + doc = self.db.collection('servers').document(discord_server_id).get() + return doc.to_dict() if doc.exists else None + except Exception as e: + print(f"Error getting server config for {discord_server_id}: {e}") + return None + + def set_server_config(self, discord_server_id: str, config: Dict[str, Any]) -> bool: + """Set Discord server configuration.""" + try: + self.db.collection('servers').document(discord_server_id).set(config) + return True + except Exception as e: + print(f"Error setting server config for {discord_server_id}: {e}") + return False + + def get_user_mapping(self, discord_user_id: str) -> Optional[Dict[str, Any]]: + """Get user's Discord-GitHub mapping across all servers.""" + try: + doc = self.db.collection('users').document(discord_user_id).get() + return doc.to_dict() if doc.exists else None + except Exception as e: + print(f"Error getting user mapping for {discord_user_id}: {e}") + return None + + def set_user_mapping(self, discord_user_id: str, mapping: Dict[str, Any]) -> bool: + """Set user's Discord-GitHub mapping.""" + try: + self.db.collection('users').document(discord_user_id).set(mapping) + return True + except Exception as e: + print(f"Error setting user mapping for {discord_user_id}: {e}") + return False + + def get_org_document(self, github_org: str, collection: str, document_id: str) -> Optional[Dict[str, Any]]: + """Get a document from an organization's collection.""" + try: + doc = self.db.collection('organizations').document(github_org).collection(collection).document(document_id).get() + return doc.to_dict() if doc.exists else None + except Exception as e: + print(f"Error getting org document {github_org}/{collection}/{document_id}: {e}") + return None + + def set_org_document(self, github_org: str, collection: str, document_id: str, data: Dict[str, Any], merge: bool = False) -> bool: + """Set a document in an organization's collection.""" + try: + self.db.collection('organizations').document(github_org).collection(collection).document(document_id).set(data, merge=merge) + return True + except Exception as e: + print(f"Error setting org document {github_org}/{collection}/{document_id}: {e}") + return False + + def update_org_document(self, github_org: str, collection: str, document_id: str, data: Dict[str, Any]) -> bool: + """Update a document in an organization's collection.""" + try: + self.db.collection('organizations').document(github_org).collection(collection).document(document_id).update(data) + return True + except Exception as e: + print(f"Error updating org document {github_org}/{collection}/{document_id}: {e}") + return False + + def query_org_collection(self, github_org: str, collection: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Query an organization's collection with optional filters.""" + try: + query = self.db.collection('organizations').document(github_org).collection(collection) + + if filters: + for field, value in filters.items(): + query = query.where(field, '==', value) + + docs = query.stream() + return {doc.id: doc.to_dict() for doc in docs} + except Exception as e: + print(f"Error querying org collection {github_org}/{collection}: {e}") + return {} + + def get_org_from_server(self, discord_server_id: str) -> Optional[str]: + """Get GitHub organization name from Discord server ID.""" + server_config = self.get_server_config(discord_server_id) + return server_config.get('github_org') if server_config else None + def _get_credentials_path() -> str: """Get the path to Firebase credentials file. @@ -112,4 +201,148 @@ def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None) return {doc.id: doc.to_dict() for doc in docs} except Exception as e: print(f"Error querying collection {collection}: {e}") - return {} \ No newline at end of file + return {} + +# Global multi-tenant instance +_mt_client = None + +def get_mt_client() -> FirestoreMultiTenant: + """Get global multi-tenant Firestore client.""" + global _mt_client + if _mt_client is None: + _mt_client = FirestoreMultiTenant() + return _mt_client + +# Legacy compatibility functions - these now require discord_server_id context +def get_document(collection: str, document_id: str, discord_server_id: str = None) -> Optional[Dict[str, Any]]: + """Get a document from Firestore. For org-scoped collections, requires discord_server_id.""" + mt_client = get_mt_client() + + # Handle organization-scoped collections + if collection in ['repo_stats', 'pr_config', 'repository_labels']: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return None + return mt_client.get_org_document(github_org, collection, document_id) + + # Handle user mappings (old 'discord' collection) + if collection == 'discord': + return mt_client.get_user_mapping(document_id) + + # Handle server configs + if collection == 'servers': + return mt_client.get_server_config(document_id) + + # Fallback to old behavior + try: + db = _get_firestore_client() + doc = db.collection(collection).document(document_id).get() + return doc.to_dict() if doc.exists else None + except Exception as e: + print(f"Error getting document {collection}/{document_id}: {e}") + return None + +def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: bool = False, discord_server_id: str = None) -> bool: + """Set a document in Firestore. For org-scoped collections, requires discord_server_id.""" + mt_client = get_mt_client() + + # Handle organization-scoped collections + if collection in ['repo_stats', 'pr_config', 'repository_labels']: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.set_org_document(github_org, collection, document_id, data, merge) + + # Handle user mappings (old 'discord' collection) + if collection == 'discord': + return mt_client.set_user_mapping(document_id, data) + + # Handle server configs + if collection == 'servers': + return mt_client.set_server_config(document_id, data) + + # Fallback to old behavior + try: + db = _get_firestore_client() + db.collection(collection).document(document_id).set(data, merge=merge) + return True + except Exception as e: + print(f"Error setting document {collection}/{document_id}: {e}") + return False + +def update_document(collection: str, document_id: str, data: Dict[str, Any], discord_server_id: str = None) -> bool: + """Update a document in Firestore. For org-scoped collections, requires discord_server_id.""" + mt_client = get_mt_client() + + # Handle organization-scoped collections + if collection in ['repo_stats', 'pr_config', 'repository_labels']: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.update_org_document(github_org, collection, document_id, data) + + # Handle user mappings (old 'discord' collection) + if collection == 'discord': + # For users, update is the same as set + return mt_client.set_user_mapping(document_id, data) + + # Fallback to old behavior + try: + db = _get_firestore_client() + db.collection(collection).document(document_id).update(data) + return True + except Exception as e: + print(f"Error updating document {collection}/{document_id}: {e}") + return False + +def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, discord_server_id: str = None) -> Dict[str, Any]: + """Query a collection with optional filters. For org-scoped collections, requires discord_server_id.""" + mt_client = get_mt_client() + + # Handle organization-scoped collections + if collection in ['repo_stats', 'pr_config', 'repository_labels']: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return {} + return mt_client.query_org_collection(github_org, collection, filters) + + # Handle user mappings (old 'discord' collection) - return all users + if collection == 'discord': + try: + db = _get_firestore_client() + query = db.collection('users') + if filters: + for field, value in filters.items(): + query = query.where(field, '==', value) + docs = query.stream() + return {doc.id: doc.to_dict() for doc in docs} + except Exception as e: + print(f"Error querying users collection: {e}") + return {} + + # Fallback to old behavior + try: + db = _get_firestore_client() + query = db.collection(collection) + + if filters: + for field, value in filters.items(): + query = query.where(field, '==', value) + + docs = query.stream() + return {doc.id: doc.to_dict() for doc in docs} + except Exception as e: + print(f"Error querying collection {collection}: {e}") + return {} \ No newline at end of file From f60d143a390f4f6a29f805566701e97d26106882 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:47:28 -0400 Subject: [PATCH 05/30] debug(pipeline): add detailed Firestore debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show all servers found in Firestore - Display server data and available keys - Track GitHub organization extraction process - Identify missing data or configuration issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/discord_bot_pipeline.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index eae9742..0d5f4cd 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -87,20 +87,29 @@ jobs: print('Getting registered organizations...') mt_client = get_mt_client() - + # Get all registered Discord servers import firebase_admin from firebase_admin import firestore db = mt_client.db servers_ref = db.collection('servers') servers = {doc.id: doc.to_dict() for doc in servers_ref.stream()} - + + print(f'Found {len(servers)} total servers in Firestore:') + for server_id, server_data in servers.items(): + print(f' Server ID: {server_id}') + print(f' Data: {server_data}') + # Extract unique GitHub organizations github_orgs = set() for server_id, server_config in servers.items(): github_org = server_config.get('github_org') if github_org: github_orgs.add(github_org) + print(f'Found GitHub org: {github_org} from server {server_id}') + else: + print(f'No github_org found in server {server_id}') + print(f'Available keys: {list(server_config.keys())}') print(f'Found {len(github_orgs)} unique organizations: {github_orgs}') From c7474cc2b324d367d4171daac7510bd901e9adcf Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:49:06 -0400 Subject: [PATCH 06/30] debug(setup): add comprehensive setup logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detailed debug logging to /complete_setup endpoint - Log all form data and Firestore operations - Add Firestore client debug with error tracing - Track configuration save process end-to-end 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- discord_bot/src/bot/auth.py | 38 +++++++++++++++++++++++++++++-------- shared/firestore.py | 10 +++++++++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index fa7f243..dda286d 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -371,32 +371,54 @@ def complete_setup(): from flask import request, render_template_string from shared.firestore import get_mt_client from datetime import datetime - + + print("=== SETUP DEBUG: /complete_setup called ===") + guild_id = request.form.get('guild_id') github_org = request.form.get('github_org', '').strip() - + + print(f"SETUP DEBUG: guild_id = {guild_id}") + print(f"SETUP DEBUG: github_org = {github_org}") + print(f"SETUP DEBUG: form data = {dict(request.form)}") + if not guild_id or not github_org: + print("SETUP DEBUG: Missing required information") return "Error: Missing required information", 400 - + # Validate GitHub organization name (basic validation) if not github_org.replace('-', '').replace('_', '').isalnum(): + print(f"SETUP DEBUG: Invalid GitHub organization name: {github_org}") return "Error: Invalid GitHub organization name", 400 - + try: + print("SETUP DEBUG: Getting Firestore client...") # Store server configuration mt_client = get_mt_client() - success = mt_client.set_server_config(guild_id, { + + config_data = { 'github_org': github_org, 'created_at': datetime.now().isoformat(), 'setup_completed': True - }) - + } + + print(f"SETUP DEBUG: Attempting to save config: {config_data}") + print(f"SETUP DEBUG: To server_id: {guild_id}") + + success = mt_client.set_server_config(guild_id, config_data) + + print(f"SETUP DEBUG: set_server_config returned: {success}") + if not success: + print("SETUP DEBUG: Failed to save configuration") return "Error: Failed to save configuration", 500 - + + print("SETUP DEBUG: Configuration saved successfully!") + # Trigger initial data collection for this organization try: + print(f"SETUP DEBUG: Triggering data pipeline for org: {github_org}") trigger_data_pipeline_for_org(github_org) + print("SETUP DEBUG: Pipeline trigger completed") except Exception as e: print(f"Warning: Failed to trigger initial data collection: {e}") # Don't fail setup if pipeline trigger fails diff --git a/shared/firestore.py b/shared/firestore.py index 2586855..d203af6 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -23,10 +23,18 @@ def get_server_config(self, discord_server_id: str) -> Optional[Dict[str, Any]]: def set_server_config(self, discord_server_id: str, config: Dict[str, Any]) -> bool: """Set Discord server configuration.""" try: - self.db.collection('servers').document(discord_server_id).set(config) + print(f"FIRESTORE DEBUG: Setting config for server {discord_server_id}") + print(f"FIRESTORE DEBUG: Config data: {config}") + + doc_ref = self.db.collection('servers').document(discord_server_id) + doc_ref.set(config) + + print(f"FIRESTORE DEBUG: Successfully set config for {discord_server_id}") return True except Exception as e: print(f"Error setting server config for {discord_server_id}: {e}") + import traceback + traceback.print_exc() return False def get_user_mapping(self, discord_user_id: str) -> Optional[Dict[str, Any]]: From 19ae8238d62662be94478ab3667ab96ea5ac8803 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 16:55:06 -0400 Subject: [PATCH 07/30] feat(ci): use different Firestore credentials based on branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use DEV_GOOGLE_CREDENTIALS_JSON for non-main branches - Use GOOGLE_CREDENTIALS_JSON for main branch - Update both discord_bot_pipeline.yml and pr-automation.yml - Ensures proper data separation between dev and prod 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/discord_bot_pipeline.yml | 9 ++++- .github/workflows/pr-automation.yml | 9 ++++- discord_bot/src/bot/auth.py | 38 +++++----------------- shared/firestore.py | 10 +----- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 0d5f4cd..b76c25d 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -42,7 +42,14 @@ jobs: pip install -r discord_bot/requirements.txt - name: Set up Google Credentials - run: echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "Using production Firestore credentials" + echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + else + echo "Using development Firestore credentials" + echo "${{ secrets.DEV_GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + fi - name: Collect GitHub Data for Multiple Organizations env: diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 261ee51..7188bf1 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -71,7 +71,14 @@ jobs: python-version: '3.9' - name: Set up Google Credentials - run: echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "Using production Firestore credentials for PR automation" + echo "${{ secrets.GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + else + echo "Using development Firestore credentials for PR automation" + echo "${{ secrets.DEV_GOOGLE_CREDENTIALS_JSON }}" | base64 --decode > discord_bot/config/credentials.json + fi - name: Install dependencies run: | diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index dda286d..fa7f243 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -371,54 +371,32 @@ def complete_setup(): from flask import request, render_template_string from shared.firestore import get_mt_client from datetime import datetime - - print("=== SETUP DEBUG: /complete_setup called ===") - + guild_id = request.form.get('guild_id') github_org = request.form.get('github_org', '').strip() - - print(f"SETUP DEBUG: guild_id = {guild_id}") - print(f"SETUP DEBUG: github_org = {github_org}") - print(f"SETUP DEBUG: form data = {dict(request.form)}") - + if not guild_id or not github_org: - print("SETUP DEBUG: Missing required information") return "Error: Missing required information", 400 - + # Validate GitHub organization name (basic validation) if not github_org.replace('-', '').replace('_', '').isalnum(): - print(f"SETUP DEBUG: Invalid GitHub organization name: {github_org}") return "Error: Invalid GitHub organization name", 400 - + try: - print("SETUP DEBUG: Getting Firestore client...") # Store server configuration mt_client = get_mt_client() - - config_data = { + success = mt_client.set_server_config(guild_id, { 'github_org': github_org, 'created_at': datetime.now().isoformat(), 'setup_completed': True - } - - print(f"SETUP DEBUG: Attempting to save config: {config_data}") - print(f"SETUP DEBUG: To server_id: {guild_id}") - - success = mt_client.set_server_config(guild_id, config_data) - - print(f"SETUP DEBUG: set_server_config returned: {success}") - + }) + if not success: - print("SETUP DEBUG: Failed to save configuration") return "Error: Failed to save configuration", 500 - - print("SETUP DEBUG: Configuration saved successfully!") - + # Trigger initial data collection for this organization try: - print(f"SETUP DEBUG: Triggering data pipeline for org: {github_org}") trigger_data_pipeline_for_org(github_org) - print("SETUP DEBUG: Pipeline trigger completed") except Exception as e: print(f"Warning: Failed to trigger initial data collection: {e}") # Don't fail setup if pipeline trigger fails diff --git a/shared/firestore.py b/shared/firestore.py index d203af6..2586855 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -23,18 +23,10 @@ def get_server_config(self, discord_server_id: str) -> Optional[Dict[str, Any]]: def set_server_config(self, discord_server_id: str, config: Dict[str, Any]) -> bool: """Set Discord server configuration.""" try: - print(f"FIRESTORE DEBUG: Setting config for server {discord_server_id}") - print(f"FIRESTORE DEBUG: Config data: {config}") - - doc_ref = self.db.collection('servers').document(discord_server_id) - doc_ref.set(config) - - print(f"FIRESTORE DEBUG: Successfully set config for {discord_server_id}") + self.db.collection('servers').document(discord_server_id).set(config) return True except Exception as e: print(f"Error setting server config for {discord_server_id}: {e}") - import traceback - traceback.print_exc() return False def get_user_mapping(self, discord_user_id: str) -> Optional[Dict[str, Any]]: From ef072b9de82668cc5b9fe0aa570d0b5825d88890 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 17:03:09 -0400 Subject: [PATCH 08/30] chore: update GitHub workflows for CI enhancements --- .github/workflows/alive.yml | 21 ++++++++++++++++----- .github/workflows/discord_bot_pipeline.yml | 6 ++---- .github/workflows/pr-automation.yml | 2 -- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/alive.yml b/.github/workflows/alive.yml index be57f6c..47ca439 100644 --- a/.github/workflows/alive.yml +++ b/.github/workflows/alive.yml @@ -3,6 +3,8 @@ name: Keep Discord Bot Alive on: schedule: - cron: '0 */6 * * *' # Every 6 hours at minute 0 + push: + pull_request: workflow_dispatch: jobs: @@ -10,17 +12,26 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 # 6 hours maximum steps: - - name: Validate CLOUD_RUN_URL + - name: Select and Validate Cloud Run URL run: | - if [ -z "${{ secrets.CLOUD_RUN_URL }}" ]; then - echo "CLOUD_RUN_URL secret is not set" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "Using production Cloud Run URL" + CLOUD_RUN_URL="${{ secrets.CLOUD_RUN_URL }}" + else + echo "Using development Cloud Run URL" + CLOUD_RUN_URL="${{ secrets.DEV_CLOUD_RUN_URL }}" + fi + + if [ -z "$CLOUD_RUN_URL" ]; then + echo "Cloud Run URL secret is not set for branch ${{ github.ref_name }}" exit 1 fi - echo "CLOUD_RUN_URL is configured" + echo "Cloud Run URL is configured: $CLOUD_RUN_URL" + echo "CLOUD_RUN_URL=$CLOUD_RUN_URL" >> $GITHUB_ENV - name: Persistent Ping Loop env: - CLOUD_RUN_URL: ${{ secrets.CLOUD_RUN_URL }} + CLOUD_RUN_URL: ${{ env.CLOUD_RUN_URL }} run: | echo "Starting persistent ping loop for 6 hours" echo "Will ping every 5 minutes (72 total pings)" diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index b76c25d..5e0cb2d 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -3,11 +3,9 @@ name: Discord Bot Data Pipeline on: schedule: - cron: '0 0 * * *' # Run daily at midnight UTC - workflow_dispatch: {} # Allow manual trigger push: - branches: - - main - - setupWizard + pull_request: + workflow_dispatch: {} # Allow manual trigger jobs: discord-bot-pipeline: diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 7188bf1..edf33e5 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -6,8 +6,6 @@ on: types: [opened, synchronize, reopened] push: - branches: - - post_visualization_refactor workflow_dispatch: inputs: From 317cb171b6fee10d39c848805d6d3be0c24b266e Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 17:46:01 -0400 Subject: [PATCH 09/30] feat: add setup wizard logic and commands for improved bot and service configuration --- discord_bot/src/bot/bot.py | 6 +-- .../src/bot/commands/admin_commands.py | 4 +- discord_bot/src/bot/commands/user_commands.py | 6 +-- discord_bot/src/services/guild_service.py | 44 ++++++++++++------- .../src/services/notification_service.py | 16 +++---- pr_review/utils/reviewer_assigner.py | 6 +-- shared/firestore.py | 14 +++--- 7 files changed, 53 insertions(+), 43 deletions(-) diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index 7d78382..bd8288e 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -104,7 +104,7 @@ async def on_guild_join(guild): import traceback traceback.print_exc() - def _check_server_configurations(self): + async def _check_server_configurations(self): """Check for any unconfigured servers and notify them.""" try: from shared.firestore import get_mt_client @@ -142,8 +142,8 @@ async def notify_unconfigured_servers(): await system_channel.send(setup_message) print(f"Sent setup reminder to server: {guild.name} (ID: {guild.id})") - # Run the async function - asyncio.create_task(notify_unconfigured_servers()) + # Run the async function directly + await notify_unconfigured_servers() except Exception as e: print(f"Error checking server configurations: {e}") diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index fcb403d..bff09e0 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -158,7 +158,7 @@ async def add_reviewer(interaction: discord.Interaction, username: str): reviewer_data['last_updated'] = __import__('time').strftime('%Y-%m-%d %H:%M:%S UTC', __import__('time').gmtime()) # Save to Firestore - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('pr_config', 'reviewers', reviewer_data, discord_server_id=discord_server_id) if success: await interaction.followup.send(f"Successfully added `{username}` to the manual reviewer pool.\nTotal reviewers: {len(all_reviewers)}") @@ -207,7 +207,7 @@ async def remove_reviewer(interaction: discord.Interaction, username: str): reviewer_data['last_updated'] = __import__('time').strftime('%Y-%m-%d %H:%M:%S UTC', __import__('time').gmtime()) # Save to Firestore - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('pr_config', 'reviewers', reviewer_data, discord_server_id=discord_server_id) if success: await interaction.followup.send(f"Successfully removed `{username}` from the manual reviewer pool.\nTotal reviewers: {len(all_reviewers)}") diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 56edaaa..1c1a574 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -96,12 +96,12 @@ async def unlink(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) discord_server_id = str(interaction.guild.id) - user_data = get_document('discord', str(interaction.user.id), discord_server_id) + user_data = get_document('discord_users', str(interaction.user.id), discord_server_id) if user_data: # Delete document by setting it to empty (Firestore will remove it) discord_server_id = str(interaction.guild.id) - set_document('discord', str(interaction.user.id), {}, discord_server_id=discord_server_id) + set_document('discord_users', str(interaction.user.id), {}, discord_server_id=discord_server_id) await interaction.followup.send( "Successfully unlinked your Discord account from your GitHub username.", ephemeral=True @@ -140,7 +140,7 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): # Get user's Discord data to find their GitHub username discord_server_id = str(interaction.guild.id) - discord_user_data = get_document('discord', user_id, discord_server_id) + discord_user_data = get_document('discord_users', user_id, discord_server_id) if not discord_user_data or not discord_user_data.get('github_id'): await interaction.followup.send( "Your Discord account is not linked to a GitHub username. Use `/link` to link it.", diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index 02f748b..2c73650 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -5,11 +5,9 @@ """ import discord -from discord.ext import commands -from typing import Dict, Any, Optional, List -import time +from typing import Dict, Any import os -from shared.firestore import get_document, set_document, update_document, query_collection +from shared.firestore import get_mt_client class GuildService: """Manages Discord guild roles and channels based on GitHub activity.""" @@ -20,12 +18,18 @@ def __init__(self, role_service = None): raise ValueError("DISCORD_BOT_TOKEN environment variable is required") self._role_service = role_service - async def update_roles_and_channels(self, user_mappings: Dict[str, str], contributions: Dict[str, Any], metrics: Dict[str, Any]) -> bool: + 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.""" 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 success = False @@ -41,15 +45,18 @@ async def on_ready(): return for guild in client.guilds: - print(f"Processing guild: {guild.name} (ID: {guild.id})") - - # Update roles - updated_count = await self._update_roles_for_guild(guild, user_mappings, contributions) - print(f"Updated {updated_count} members in {guild.name}") - - # Update channels - await self._update_channels_for_guild(guild, metrics) - print(f"Updated channels in {guild.name}") + if str(guild.id) == discord_server_id: + print(f"Processing guild: {guild.name} (ID: {guild.id})") + + # Update roles with organization-specific data + updated_count = await self._update_roles_for_guild(guild, user_mappings, contributions, github_org) + print(f"Updated {updated_count} members in {guild.name}") + + # Update channels + await self._update_channels_for_guild(guild, 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") @@ -71,13 +78,16 @@ async def on_ready(): traceback.print_exc() return False - async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dict[str, str], contributions: Dict[str, Any]) -> int: + async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dict[str, str], contributions: Dict[str, Any], github_org: str) -> int: """Update roles for a single guild using role service.""" if not self._role_service: print("Role service not available - skipping role updates") return 0 - - hall_of_fame_data = self._role_service.get_hall_of_fame_data() + + # Get organization-specific hall of fame data + from shared.firestore import get_mt_client + mt_client = get_mt_client() + hall_of_fame_data = mt_client.get_org_document(github_org, 'repo_stats', 'hall_of_fame') if github_org else None medal_assignments = self._role_service.get_medal_assignments(hall_of_fame_data or {}) obsolete_roles = self._role_service.get_obsolete_role_names() diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index f309c7f..b27da33 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -217,7 +217,7 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, async def _get_webhook_url(self, notification_type: str) -> Optional[str]: """Get webhook URL for specified notification type.""" try: - webhook_config = get_document('notification_config', 'webhooks') + webhook_config = get_document('global_config', 'ci_cd_webhooks') if not webhook_config: return None @@ -255,11 +255,11 @@ class WebhookManager: def set_webhook_url(notification_type: str, webhook_url: str) -> bool: """Set webhook URL for specified notification type.""" try: - webhook_config = get_document('notification_config', 'webhooks') or {} + webhook_config = get_document('global_config', 'ci_cd_webhooks') or {} webhook_config[f'{notification_type}_webhook_url'] = webhook_url webhook_config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'webhooks', webhook_config) + return set_document('global_config', 'ci_cd_webhooks', webhook_config) except Exception as e: logger.error(f"Failed to set webhook URL: {e}") return False @@ -268,7 +268,7 @@ def set_webhook_url(notification_type: str, webhook_url: str) -> bool: def get_monitored_repositories() -> List[str]: """Get list of repositories being monitored for CI/CD notifications.""" try: - config = get_document('notification_config', 'monitored_repos') + config = get_document('global_config', 'monitored_repositories') if not config: return [] return config.get('repositories', []) @@ -280,7 +280,7 @@ def get_monitored_repositories() -> List[str]: def add_monitored_repository(repo: str) -> bool: """Add repository to CI/CD monitoring list.""" try: - config = get_document('notification_config', 'monitored_repos') or {'repositories': []} + config = get_document('global_config', 'monitored_repositories') or {'repositories': []} repos = config.get('repositories', []) if repo not in repos: @@ -288,7 +288,7 @@ def add_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'monitored_repos', config) + return set_document('global_config', 'monitored_repositories', config) return True # Already exists except Exception as e: logger.error(f"Failed to add monitored repository: {e}") @@ -298,7 +298,7 @@ def add_monitored_repository(repo: str) -> bool: def remove_monitored_repository(repo: str) -> bool: """Remove repository from CI/CD monitoring list.""" try: - config = get_document('notification_config', 'monitored_repos') + config = get_document('global_config', 'monitored_repositories') if not config: return False @@ -308,7 +308,7 @@ def remove_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'monitored_repos', config) + return set_document('global_config', 'monitored_repositories', config) return True # Already removed except Exception as e: logger.error(f"Failed to remove monitored repository: {e}") diff --git a/pr_review/utils/reviewer_assigner.py b/pr_review/utils/reviewer_assigner.py index 8b8703e..f08131f 100644 --- a/pr_review/utils/reviewer_assigner.py +++ b/pr_review/utils/reviewer_assigner.py @@ -21,8 +21,8 @@ def __init__(self, config_path: Optional[str] = None): def _load_reviewers(self) -> List[str]: """Load reviewer pool from Firestore configuration.""" try: - logger.info("REVIEWER DEBUG: Attempting to load reviewers from pr_config/reviewers") - reviewer_data = get_document('pr_config', 'reviewers') + logger.info("REVIEWER DEBUG: Attempting to load reviewers from global_config/reviewer_pool") + reviewer_data = get_document('global_config', 'reviewer_pool') if reviewer_data and 'reviewers' in reviewer_data: reviewers = reviewer_data['reviewers'] @@ -104,7 +104,7 @@ def save_config(self): 'count': len(self.reviewers), 'last_updated': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()) } - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('global_config', 'reviewer_pool', reviewer_data) if success: logger.info(f"Saved {len(self.reviewers)} reviewers to Firestore") else: diff --git a/shared/firestore.py b/shared/firestore.py index 2586855..a8dafc7 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -14,34 +14,34 @@ def __init__(self): def get_server_config(self, discord_server_id: str) -> Optional[Dict[str, Any]]: """Get Discord server configuration including GitHub org mapping.""" try: - doc = self.db.collection('servers').document(discord_server_id).get() + doc = self.db.collection('discord_servers').document(discord_server_id).get() return doc.to_dict() if doc.exists else None except Exception as e: print(f"Error getting server config for {discord_server_id}: {e}") return None - + def set_server_config(self, discord_server_id: str, config: Dict[str, Any]) -> bool: """Set Discord server configuration.""" try: - self.db.collection('servers').document(discord_server_id).set(config) + self.db.collection('discord_servers').document(discord_server_id).set(config) return True except Exception as e: print(f"Error setting server config for {discord_server_id}: {e}") return False - + def get_user_mapping(self, discord_user_id: str) -> Optional[Dict[str, Any]]: """Get user's Discord-GitHub mapping across all servers.""" try: - doc = self.db.collection('users').document(discord_user_id).get() + doc = self.db.collection('discord_users').document(discord_user_id).get() return doc.to_dict() if doc.exists else None except Exception as e: print(f"Error getting user mapping for {discord_user_id}: {e}") return None - + def set_user_mapping(self, discord_user_id: str, mapping: Dict[str, Any]) -> bool: """Set user's Discord-GitHub mapping.""" try: - self.db.collection('users').document(discord_user_id).set(mapping) + self.db.collection('discord_users').document(discord_user_id).set(mapping) return True except Exception as e: print(f"Error setting user mapping for {discord_user_id}: {e}") From 5e044a47e66c14daf5fc5ed8a9ab8e7172865e78 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 26 Oct 2025 18:08:05 -0400 Subject: [PATCH 10/30] feat: implement setup wizard and update user commands --- .github/workflows/discord_bot_pipeline.yml | 10 +- discord_bot/src/bot/commands/user_commands.py | 141 ++++++++++-------- 2 files changed, 84 insertions(+), 67 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 5e0cb2d..de29959 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -97,7 +97,7 @@ jobs: import firebase_admin from firebase_admin import firestore db = mt_client.db - servers_ref = db.collection('servers') + servers_ref = db.collection('discord_servers') servers = {doc.id: doc.to_dict() for doc in servers_ref.stream()} print(f'Found {len(servers)} total servers in Firestore:') @@ -241,7 +241,9 @@ jobs: print(f'Stored labels for {labels_stored} repositories in {github_org}') # Update user contribution data - user_mappings = mt_client.query_org_collection('users', 'discord') # Get all users + user_mappings = {} + for doc in mt_client.db.collection('discord_users').stream(): + user_mappings[doc.id] = doc.to_dict() stored_count = 0 for username, user_data in contributions.items(): @@ -282,7 +284,7 @@ jobs: guild_service = GuildService(role_service) # Get all registered Discord servers - servers_ref = mt_client.db.collection('servers') + servers_ref = mt_client.db.collection('discord_servers') servers = {doc.id: doc.to_dict() for doc in servers_ref.stream()} print(f'Found {len(servers)} registered Discord servers') @@ -301,7 +303,7 @@ jobs: repo_metrics = org_data['repo_metrics'] # Get user mappings for this server's organization - user_mappings_data = mt_client.db.collection('users').stream() + user_mappings_data = mt_client.db.collection('discord_users').stream() user_mappings = {} for doc in user_mappings_data: user_data = doc.to_dict() diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 1c1a574..24a9dd8 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -14,10 +14,29 @@ class UserCommands: """Handles user-related Discord commands.""" - + def __init__(self, bot): self.bot = bot self.verification_lock = threading.Lock() + + async def _safe_defer(self, interaction): + """Safely defer interaction with error handling.""" + try: + await interaction.response.defer(ephemeral=True) + except discord.errors.InteractionResponded: + # Interaction was already responded to, continue anyway + pass + + async def _safe_followup(self, interaction, message, embed=False): + """Safely send followup message with error handling.""" + try: + if embed: + await interaction.followup.send(embed=message, ephemeral=True) + else: + await interaction.followup.send(message, ephemeral=True) + except discord.errors.InteractionResponded: + # Interaction was already responded to, continue anyway + pass def register_commands(self): """Register all user commands with the bot.""" @@ -30,17 +49,17 @@ def _link_command(self): """Create the link command.""" @app_commands.command(name="link", description="Link your Discord to GitHub") async def link(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) + await self._safe_defer(interaction) if not self.verification_lock.acquire(blocking=False): - await interaction.followup.send("The verification process is currently busy. Please try again later.", ephemeral=True) + await self._safe_followup(interaction, "The verification process is currently busy. Please try again later.") return try: discord_user_id = str(interaction.user.id) - + oauth_url = get_github_username_for_user(discord_user_id) - await interaction.followup.send(f"Please complete GitHub authentication: {oauth_url}", ephemeral=True) + await self._safe_followup(interaction, f"Please complete GitHub authentication: {oauth_url}") github_username = await asyncio.get_event_loop().run_in_executor( None, wait_for_username, discord_user_id @@ -48,17 +67,17 @@ async def link(interaction: discord.Interaction): if github_username: discord_server_id = str(interaction.guild.id) - + # Get existing user data or create new from shared.firestore import get_mt_client mt_client = get_mt_client() existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} - + # Add this server to user's server list servers_list = existing_user_data.get('servers', []) if discord_server_id not in servers_list: servers_list.append(discord_server_id) - + # Update user mapping with server association user_data = { 'github_id': github_username, @@ -70,19 +89,19 @@ async def link(interaction: discord.Interaction): 'last_linked_server': discord_server_id, 'last_updated': str(interaction.created_at) } - + mt_client.set_user_mapping(discord_user_id, user_data) - + # Trigger the data pipeline to collect stats for the new user await self._trigger_data_pipeline() - - await interaction.followup.send(f"Successfully linked to GitHub user: `{github_username}`\n Your stats will be available within a few minutes!", ephemeral=True) + + await self._safe_followup(interaction, f"Successfully linked to GitHub user: `{github_username}`\n Your stats will be available within a few minutes!") else: - await interaction.followup.send("Authentication timed out or failed. Please try again.", ephemeral=True) + await self._safe_followup(interaction, "Authentication timed out or failed. Please try again.") except Exception as e: print("Error in /link:", e) - await interaction.followup.send("Failed to link GitHub account.", ephemeral=True) + await self._safe_followup(interaction, "Failed to link GitHub account.") finally: self.verification_lock.release() @@ -93,7 +112,7 @@ def _unlink_command(self): @app_commands.command(name="unlink", description="Unlinks your Discord account from your GitHub username") async def unlink(interaction: discord.Interaction): try: - await interaction.response.defer(ephemeral=True) + await self._safe_defer(interaction) discord_server_id = str(interaction.guild.id) user_data = get_document('discord_users', str(interaction.user.id), discord_server_id) @@ -102,20 +121,14 @@ async def unlink(interaction: discord.Interaction): # Delete document by setting it to empty (Firestore will remove it) discord_server_id = str(interaction.guild.id) set_document('discord_users', str(interaction.user.id), {}, discord_server_id=discord_server_id) - await interaction.followup.send( - "Successfully unlinked your Discord account from your GitHub username.", - ephemeral=True - ) + await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") print(f"Unlinked Discord user {interaction.user.name}") else: - await interaction.followup.send( - "Your Discord account is not linked to any GitHub username.", - ephemeral=True - ) + await self._safe_followup(interaction, "Your Discord account is not linked to any GitHub username.") except Exception as e: print(f"Error unlinking user: {e}") - await interaction.followup.send("An error occurred while unlinking your account.", ephemeral=True) + await self._safe_followup(interaction, "An error occurred while unlinking your account.") return unlink @@ -129,48 +142,45 @@ def _getstats_command(self): app_commands.Choice(name="Commits", value="commit") ]) async def getstats(interaction: discord.Interaction, type: str = "pr"): - await interaction.response.defer() - + try: + await self._safe_defer(interaction) + except Exception: + pass + try: stats_type = type.lower().strip() if stats_type not in ["pr", "issue", "commit"]: stats_type = "pr" - + user_id = str(interaction.user.id) - + # Get user's Discord data to find their GitHub username discord_server_id = str(interaction.guild.id) discord_user_data = get_document('discord_users', user_id, discord_server_id) if not discord_user_data or not discord_user_data.get('github_id'): - await interaction.followup.send( - "Your Discord account is not linked to a GitHub username. Use `/link` to link it.", - ephemeral=True - ) + await self._safe_followup(interaction, "Your Discord account is not linked to a GitHub username. Use `/link` to link it.") return - + github_username = discord_user_data['github_id'] - + # Use the Discord user data which should contain the full contribution stats # The pipeline updates Discord documents with full contribution data user_data = discord_user_data - + if not user_data: - await interaction.followup.send( - f"No contribution data found for GitHub user '{github_username}'.", - ephemeral=True - ) + await self._safe_followup(interaction, f"No contribution data found for GitHub user '{github_username}'.") return # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) if embed: - await interaction.followup.send(embed=embed) - + await self._safe_followup(interaction, embed, embed=True) + except Exception as e: print(f"Error in getstats command: {e}") import traceback traceback.print_exc() - await interaction.followup.send("Unable to retrieve your stats. This might be because you just linked your account and your data isn't populated yet. Please try again in a few minutes!", ephemeral=True) + await self._safe_followup(interaction, "Unable to retrieve your stats. This might be because you just linked your account and your data isn't populated yet. Please try again in a few minutes!") return getstats @@ -190,23 +200,31 @@ def _halloffame_command(self): app_commands.Choice(name="Daily", value="daily") ]) async def halloffame(interaction: discord.Interaction, type: str = "pr", period: str = "all_time"): - await interaction.response.defer() - - discord_server_id = str(interaction.guild.id) - hall_of_fame_data = get_document('repo_stats', 'hall_of_fame', discord_server_id) - - if not hall_of_fame_data: - await interaction.followup.send("Hall of fame data not available yet.", ephemeral=True) - return - - top_3 = hall_of_fame_data.get(type, {}).get(period, []) - - if not top_3: - await interaction.followup.send(f"No data for {type} {period}.", ephemeral=True) - return - - embed = self._create_halloffame_embed(top_3, type, period, hall_of_fame_data.get('last_updated')) - await interaction.followup.send(embed=embed) + try: + await self._safe_defer(interaction) + except Exception: + pass + + try: + discord_server_id = str(interaction.guild.id) + hall_of_fame_data = get_document('repo_stats', 'hall_of_fame', discord_server_id) + + if not hall_of_fame_data: + await self._safe_followup(interaction, "Hall of fame data not available yet.") + return + + top_3 = hall_of_fame_data.get(type, {}).get(period, []) + + if not top_3: + await self._safe_followup(interaction, f"No data for {type} {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) + + except Exception as e: + print(f"Error in halloffame command: {e}") + await self._safe_followup(interaction, "Unable to retrieve hall of fame data.") return halloffame @@ -241,10 +259,7 @@ async def _create_stats_embed(self, user_data, github_username, stats_type, inte # Check if stats data exists stats = user_data.get("stats") if not stats or stats_field not in stats: - await interaction.followup.send( - "Your stats are being collected! Please check back in 5 min after the bot has gathered your contribution data.", - ephemeral=True - ) + await self._safe_followup(interaction, "Your stats are being collected! Please check back in 5 min after the bot has gathered your contribution data.") return None # Get enhanced stats From f6e9f20529784e6074ae3bd801df915f952fff12 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 9 Dec 2025 01:40:33 +0700 Subject: [PATCH 11/30] chores: document DISCORD_BOT_CLIENT_ID setup chores: document DISCORD_BOT_CLIENT_ID setup --- discord_bot/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord_bot/README.md b/discord_bot/README.md index 9a59f72..1c6c44c 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -167,6 +167,10 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - Click "Reset Token" → Copy the token - **Add to `.env`:** `DISCORD_BOT_TOKEN=your_token_here` - **Add to GitHub Secrets:** Create secret named `DISCORD_BOT_TOKEN` +8. **Grab the Discord bot client ID:** + - Stay in the same Discord application and open the **General Information** tab + - Copy the **Application ID** (this is sometimes labeled "Client ID") + - **Add to `.env`:** `DISCORD_BOT_CLIENT_ID=your_application_id` ### Step 2: Get credentials.json (config file) + GOOGLE_CREDENTIALS_JSON (GitHub Secret) From 962913c28b9d2d3a179fd0be24ee28eea9b5297e Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 10 Dec 2025 15:56:58 +0700 Subject: [PATCH 12/30] docs: document dev secrets and GH_TOKEN usage --- discord_bot/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/discord_bot/README.md b/discord_bot/README.md index 1c6c44c..1fc437c 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -117,11 +117,17 @@ cp discord_bot/config/.env.example discord_bot/config/.env **GitHub repository secrets you need to configure:** Go to your GitHub repository → Settings → Secrets and variables → Actions → Click "New repository secret" for each: - `DISCORD_BOT_TOKEN` -- `GH_TOKEN` +- `GH_TOKEN` - `GOOGLE_CREDENTIALS_JSON` - `REPO_OWNER` - `CLOUD_RUN_URL` +If you plan to run GitHub Actions from branches other than `main`, also add the matching development secrets so the workflows can deploy correctly: +- `DEV_GOOGLE_CREDENTIALS_JSON` +- `DEV_CLOUD_RUN_URL` + +> The workflows only reference `GH_TOKEN`, so you can reuse the same PAT for all branches. + --- # 4. Step-by-Step Configuration @@ -218,6 +224,7 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - Paste the JSON content and encode it to base64 - Copy the base64 string - **Add to GitHub Secrets:** Create secret named `GOOGLE_CREDENTIALS_JSON` with the base64 string + - *(Do this for non-main branches)* Create another secret named `DEV_GOOGLE_CREDENTIALS_JSON` with the same base64 string so development branches can run GitHub Actions. ### Step 3: Get GITHUB_TOKEN (.env) + GH_TOKEN (GitHub Secret) @@ -263,6 +270,7 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - **Add to `.env`:** `OAUTH_BASE_URL=YOUR_CLOUD_RUN_URL` - **Example:** `OAUTH_BASE_URL=https://discord-bot-abcd1234-uc.a.run.app` - **Add to GitHub Secrets:** Create secret named `CLOUD_RUN_URL` with the same URL + - *(Do this for non-main branches)* Create a `DEV_CLOUD_RUN_URL` pointing to the staging/test Cloud Run service so development workflows continue to function. (You may reuse CLOUD_RUN_URL if you are not deploying production from main.) 3. **Configure Discord OAuth Redirect URI:** - Go to [Discord Developer Portal](https://discord.com/developers/applications) From 95972e0815ef1c9de2d6b9687a458414368d006f Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 10 Dec 2025 15:59:09 +0700 Subject: [PATCH 13/30] chore: standardize pr-automation using GH_TOKEN --- .github/workflows/pr-automation.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index edf33e5..40a03f5 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -39,7 +39,7 @@ on: default: 'process_pr' description: 'Action to perform' secrets: - DEV_GH_TOKEN: + GH_TOKEN: required: true GOOGLE_API_KEY: required: true @@ -61,7 +61,7 @@ jobs: # If called from another repo, checkout this master repo repository: ${{ github.event_name == 'workflow_call' && 'ruxailab/disgitbot' || github.repository }} path: ${{ github.event_name == 'workflow_call' && 'pr-automation' || '.' }} - token: ${{ secrets.DEV_GH_TOKEN || github.token }} + token: ${{ secrets.GH_TOKEN || github.token }} - name: Set up Python uses: actions/setup-python@v4 @@ -85,7 +85,7 @@ jobs: - name: Run PR automation env: - GITHUB_TOKEN: ${{ secrets.DEV_GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_APPLICATION_CREDENTIALS: discord_bot/config/credentials.json PYTHONPATH: ${{ github.workspace }} From bf965d21d2e3a7f5ab94fae28541c82f7bb8892f Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 10 Dec 2025 16:04:22 +0700 Subject: [PATCH 14/30] fix: passes github_org & call right function --- .github/workflows/discord_bot_pipeline.yml | 12 ++++-- .../pipeline/processors/reviewer_processor.py | 40 ++++++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index de29959..24e8e1c 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -5,7 +5,11 @@ on: - cron: '0 0 * * *' # Run daily at midnight UTC push: pull_request: - workflow_dispatch: {} # Allow manual trigger + workflow_dispatch: + inputs: + organization: + description: "GitHub org to collect stats for" + required: true jobs: discord-bot-pipeline: @@ -51,7 +55,7 @@ jobs: - name: Collect GitHub Data for Multiple Organizations env: - GITHUB_TOKEN: ${{ secrets.DEV_GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | @@ -169,7 +173,7 @@ jobs: processed_labels = metrics_functions.process_repository_labels(raw_data) print('Generating reviewer pool...') - reviewer_pool = reviewer_functions.generate_reviewer_pool(contributions) + reviewer_pool = reviewer_functions.generate_reviewer_pool(contributions, github_org=github_org) contributor_summary = reviewer_functions.get_contributor_summary(contributions) print(f'Processed {len(contributions)} contributors for {github_org}') @@ -318,7 +322,7 @@ jobs: # Update Discord roles and channels for this server import asyncio - success = asyncio.run(guild_service.update_server_roles_and_channels( + 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}') diff --git a/discord_bot/src/pipeline/processors/reviewer_processor.py b/discord_bot/src/pipeline/processors/reviewer_processor.py index 3d541a1..a111af5 100644 --- a/discord_bot/src/pipeline/processors/reviewer_processor.py +++ b/discord_bot/src/pipeline/processors/reviewer_processor.py @@ -5,37 +5,47 @@ """ import time -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional -def generate_reviewer_pool(all_contributions: Dict[str, Any], max_reviewers: int = 7) -> Dict[str, Any]: +from shared.firestore import get_mt_client, get_document + + +def generate_reviewer_pool( + all_contributions: Dict[str, Any], + max_reviewers: int = 7, + github_org: Optional[str] = None, +) -> Dict[str, Any]: """Generate reviewer pool with separate top contributor and manual pools.""" print("Generating reviewer pool from top contributors...") - + if not all_contributions: return {} - - # Get existing reviewer configuration to preserve manual reviewers - from shared.firestore import get_document - existing_config = get_document('pr_config', 'reviewers') or {} + + if github_org: + existing_config = ( + get_mt_client().get_org_document(github_org, 'pr_config', 'reviewers') or {} + ) + else: + existing_config = get_document('pr_config', 'reviewers') or {} manual_reviewers = existing_config.get('manual_reviewers', []) - + # Get contributors sorted by PR count (all-time) top_contributors = sorted( all_contributions.items(), key=lambda x: x[1].get('stats', {}).get('pr', {}).get('all_time', x[1].get('pr_count', 0)), - reverse=True + reverse=True, )[:max_reviewers] - + # Create top contributor reviewer list - top_contributor_reviewers = [] + top_contributor_reviewers: List[str] = [] for contributor, data in top_contributors: pr_count = data.get('stats', {}).get('pr', {}).get('all_time', data.get('pr_count', 0)) if pr_count > 0: # Only include contributors with at least 1 PR top_contributor_reviewers.append(contributor) - + # Combine both pools for total reviewer list all_reviewers = list(set(top_contributor_reviewers + manual_reviewers)) - + return { 'reviewers': all_reviewers, 'top_contributor_reviewers': top_contributor_reviewers, @@ -43,7 +53,7 @@ def generate_reviewer_pool(all_contributions: Dict[str, Any], max_reviewers: int 'count': len(all_reviewers), 'selection_criteria': 'top_pr_contributors_plus_manual', 'last_updated': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), - 'generated_from_total': len(all_contributions) + 'generated_from_total': len(all_contributions), } def get_contributor_summary(all_contributions: Dict[str, Any]) -> Dict[str, Any]: @@ -69,4 +79,4 @@ def get_contributor_summary(all_contributions: Dict[str, Any]) -> Dict[str, Any] 'top_contributors': contributors_by_prs[:15], 'total_contributors': len(contributors_by_prs), 'criteria': 'sorted_by_pr_count' - } \ No newline at end of file + } From 755f7a7980be5f5d960394db5cc632a528773416 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 10 Dec 2025 16:21:46 +0700 Subject: [PATCH 15/30] fix: not flexible org name --- discord_bot/src/bot/commands/user_commands.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 24a9dd8..9d6c329 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -10,7 +10,7 @@ import threading from ...services.role_service import RoleService from ..auth import get_github_username_for_user, wait_for_username -from shared.firestore import get_document, set_document +from shared.firestore import get_document, set_document, get_mt_client class UserCommands: """Handles user-related Discord commands.""" @@ -266,9 +266,21 @@ async def _create_stats_embed(self, user_data, github_username, stats_type, inte type_stats = stats[stats_field] # Create enhanced embed + discord_server_id = str(interaction.guild.id) if interaction.guild else None + org_name = None + if discord_server_id: + try: + org_name = get_mt_client().get_org_from_server(discord_server_id) + except Exception as e: + print(f"Error fetching org for server {discord_server_id}: {e}") + + org_label = org_name or "your linked" embed = discord.Embed( title=f"GitHub Contribution Metrics for {github_username}", - description=f"Stats tracked across all RUXAILAB repositories. Updated daily. Last update: {stats.get('last_updated', datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC'))}", + description=( + f"Stats tracked across {org_label} repositories. " + f"Updated daily. Last update: {stats.get('last_updated', datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC'))}" + ), color=discord.Color.blue() ) @@ -371,4 +383,4 @@ async def _trigger_data_pipeline(self): return False except Exception as e: print(f"Error triggering pipeline: {e}") - return False \ No newline at end of file + return False From a3a52567b4cf3e4765c5bce7f28669eef8d25844 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Thu, 11 Dec 2025 13:40:00 +0700 Subject: [PATCH 16/30] docs: align role update instructions with pipeline workflow --- discord_bot/README.md | 5 +++-- roles.sh | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 100755 roles.sh diff --git a/discord_bot/README.md b/discord_bot/README.md index 1fc437c..2d6e39c 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -364,9 +364,10 @@ The deployment script will: # Set your repository as default for GitHub CLI gh repo set-default - # Trigger the workflow to fetch data and assign roles - gh workflow run update-discord-roles.yml + # Trigger the data pipeline to fetch data and assign roles + gh workflow run discord_bot_pipeline.yml -f organization= ``` + Use the same organization name you configured in `REPO_OWNER` when invoking the workflow (for example `-f organization=ruxailab`). This runs the full data pipeline, pushes metrics to Firestore, and refreshes Discord roles/channels for every registered server. --- diff --git a/roles.sh b/roles.sh deleted file mode 100755 index 06ac478..0000000 --- a/roles.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -./venv/bin/python discord_bot/update_discord_roles.py \ No newline at end of file From 94e4b5cd3a4d105155fb88a68f2f7b3e6a9970d8 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 26 Dec 2025 20:58:55 +0700 Subject: [PATCH 17/30] feat: add installation flow and app auth --- .github/workflows/discord_bot_pipeline.yml | 34 +- discord_bot/config/.env.example | 5 +- discord_bot/deployment/deploy.sh | 82 ++++- discord_bot/requirements.txt | 1 + discord_bot/src/bot/auth.py | 291 ++++++++++++------ .../src/services/github_app_service.py | 89 ++++++ discord_bot/src/services/github_service.py | 69 ++++- discord_bot/src/utils/env_validator.py | 26 +- 8 files changed, 463 insertions(+), 134 deletions(-) create mode 100644 discord_bot/src/services/github_app_service.py diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 24e8e1c..aec3fcc 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -55,7 +55,8 @@ jobs: - name: Collect GitHub Data for Multiple Organizations env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_APP_ID: ${{ secrets.GITHUB_APP_ID }} + GITHUB_APP_PRIVATE_KEY_B64: ${{ secrets.GITHUB_APP_PRIVATE_KEY_B64 }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | @@ -93,6 +94,7 @@ jobs: sys.path.insert(0, 'src') from services.github_service import GitHubService + from services.github_app_service import GitHubAppService print('Getting registered organizations...') mt_client = get_mt_client() @@ -109,24 +111,30 @@ jobs: print(f' Server ID: {server_id}') print(f' Data: {server_data}') - # Extract unique GitHub organizations - github_orgs = set() + # Extract unique GitHub installations (preferred) with a stable org key + installations = {} for server_id, server_config in servers.items(): + installation_id = server_config.get('github_installation_id') github_org = server_config.get('github_org') - if github_org: - github_orgs.add(github_org) - print(f'Found GitHub org: {github_org} from server {server_id}') + if installation_id and github_org: + installations[int(installation_id)] = github_org + print(f'Found installation: {installation_id} for {github_org} (server {server_id})') else: - print(f'No github_org found in server {server_id}') + print(f'Skipping server {server_id}: missing github_installation_id or github_org') print(f'Available keys: {list(server_config.keys())}') + + print(f'Found {len(installations)} unique installations: {installations}') - print(f'Found {len(github_orgs)} unique organizations: {github_orgs}') - - # Collect data for each organization + # Collect data for each installation (GitHub App token) all_org_data = {} - for github_org in github_orgs: - print(f'Collecting data for organization: {github_org}') - github_service = GitHubService(github_org) + gh_app = GitHubAppService() + for installation_id, github_org in installations.items(): + print(f'Collecting data for installation {installation_id} ({github_org})') + token = gh_app.get_installation_access_token(installation_id) + if not token: + print(f'Failed to get installation token for {installation_id}, skipping') + continue + github_service = GitHubService(github_org, token=token, installation_id=installation_id) raw_data = github_service.collect_organization_data() all_org_data[github_org] = raw_data print(f'Collected data for {len(raw_data.get(\"repositories\", {}))} repositories in {github_org}') diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index 28ee8c6..ebf50d6 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -4,4 +4,7 @@ GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= REPO_OWNER= OAUTH_BASE_URL= -DISCORD_BOT_CLIENT_ID= \ No newline at end of file +DISCORD_BOT_CLIENT_ID= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY_B64= +GITHUB_APP_SLUG= diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 9ef76de..96842f7 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -12,6 +12,11 @@ PURPLE='\033[0;35m' CYAN='\033[0;36m' NC='\033[0m' # No Color +FZF_AVAILABLE=0 +if command -v fzf &>/dev/null; then + FZF_AVAILABLE=1 +fi + # Helper functions print_header() { echo -e "\n${PURPLE}================================${NC}" @@ -43,6 +48,12 @@ ENV_PATH="$ROOT_DIR/config/.env" print_header +if [ "$FZF_AVAILABLE" -eq 1 ]; then + print_success "fzf detected: you can type to filter options in selection menus." +else + print_warning "fzf not detected; falling back to arrow-key menu navigation." +fi + # Check if gcloud is installed and authenticated print_step "Checking Google Cloud CLI..." if ! command -v gcloud &> /dev/null; then @@ -132,6 +143,31 @@ interactive_select() { done } +fuzzy_select_or_fallback() { + local prompt="$1" + shift + local options=("$@") + + if [ "$FZF_AVAILABLE" -eq 1 ]; then + local selection + selection=$(printf '%s\n' "${options[@]}" | fzf --prompt="$prompt> " --height=15 --border --exit-0) + if [ -z "$selection" ]; then + print_warning "Selection cancelled." + exit 0 + fi + for i in "${!options[@]}"; do + if [[ "${options[$i]}" == "$selection" ]]; then + INTERACTIVE_SELECTION=$i + return + fi + done + print_error "Unable to match selection." + exit 1 + else + interactive_select "$prompt" "${options[@]}" + fi +} + # Function to select Google Cloud Project select_project() { print_step "Fetching your Google Cloud projects..." @@ -156,7 +192,7 @@ select_project() { done <<< "$projects" # Interactive selection - interactive_select "Select a Google Cloud Project:" "${project_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Project" "${project_options[@]}" selection=$INTERACTIVE_SELECTION PROJECT_ID="${project_ids[$selection]}" @@ -297,14 +333,8 @@ create_new_env_file() { print_warning "Discord Bot Token is required!" done - # GitHub Token - while true; do - read -p "GitHub Token: " github_token - if [ -n "$github_token" ]; then - break - fi - print_warning "GitHub Token is required!" - done + # GitHub Token (optional for GitHub App mode) + read -p "GitHub Token (optional): " github_token # GitHub Client ID read -p "GitHub Client ID: " github_client_id @@ -317,6 +347,14 @@ create_new_env_file() { # OAuth Base URL (optional - will auto-detect on Cloud Run) read -p "OAuth Base URL (optional): " oauth_base_url + + # Discord Bot Client ID + read -p "Discord Bot Client ID: " discord_bot_client_id + + # GitHub App configuration (invite-only mode) + read -p "GitHub App ID: " github_app_id + read -p "GitHub App Private Key (base64): " github_app_private_key_b64 + read -p "GitHub App Slug: " github_app_slug # Create .env file cat > "$ENV_PATH" << EOF @@ -326,6 +364,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file created successfully!" @@ -355,6 +397,18 @@ edit_env_file() { read -p "OAuth Base URL [$OAUTH_BASE_URL]: " new_oauth_base_url oauth_base_url=${new_oauth_base_url:-$OAUTH_BASE_URL} + + read -p "Discord Bot Client ID [$DISCORD_BOT_CLIENT_ID]: " new_discord_bot_client_id + discord_bot_client_id=${new_discord_bot_client_id:-$DISCORD_BOT_CLIENT_ID} + + read -p "GitHub App ID [$GITHUB_APP_ID]: " new_github_app_id + github_app_id=${new_github_app_id:-$GITHUB_APP_ID} + + read -p "GitHub App Private Key (base64) [$GITHUB_APP_PRIVATE_KEY_B64]: " new_github_app_private_key_b64 + github_app_private_key_b64=${new_github_app_private_key_b64:-$GITHUB_APP_PRIVATE_KEY_B64} + + read -p "GitHub App Slug [$GITHUB_APP_SLUG]: " new_github_app_slug + github_app_slug=${new_github_app_slug:-$GITHUB_APP_SLUG} # Update .env file cat > "$ENV_PATH" << EOF @@ -364,6 +418,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file updated successfully!" @@ -469,7 +527,7 @@ get_deployment_config() { "custom" ) - interactive_select "Select a Google Cloud Region:" "${region_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Region" "${region_options[@]}" region_choice=$INTERACTIVE_SELECTION if [ $region_choice -eq 5 ]; then # Custom region @@ -489,7 +547,7 @@ get_deployment_config() { declare -a memory_values=("512Mi" "1Gi" "2Gi" "custom") declare -a cpu_values=("1" "1" "2" "custom") - interactive_select "Select Resource Configuration:" "${resource_options[@]}" + fuzzy_select_or_fallback "Select Resource Configuration" "${resource_options[@]}" resource_choice=$INTERACTIVE_SELECTION if [ $resource_choice -eq 3 ]; then # Custom @@ -737,4 +795,4 @@ main() { } # Run main function -main \ No newline at end of file +main diff --git a/discord_bot/requirements.txt b/discord_bot/requirements.txt index 82bac54..903042f 100644 --- a/discord_bot/requirements.txt +++ b/discord_bot/requirements.txt @@ -10,3 +10,4 @@ python-dateutil==2.8.2 Werkzeug==3.0.1 matplotlib>=3.9.2 numpy>=2.0.0 +PyJWT[crypto]==2.9.0 diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index fa7f243..8dac737 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -5,6 +5,7 @@ from flask_dance.contrib.github import make_github_blueprint, github from dotenv import load_dotenv from werkzeug.middleware.proxy_fix import ProxyFix +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired load_dotenv() @@ -34,9 +35,12 @@ def create_oauth_app(): github_blueprint = make_github_blueprint( client_id=os.getenv("GITHUB_CLIENT_ID"), client_secret=os.getenv("GITHUB_CLIENT_SECRET"), - redirect_url=f"{base_url}/auth/callback" + redirect_url=f"{base_url}/auth/callback", + scope="read:org" ) app.register_blueprint(github_blueprint, url_prefix="/login") + + state_serializer = URLSafeTimedSerializer(app.secret_key, salt="github-app-install") @app.route("/") def index(): @@ -46,7 +50,9 @@ def index(): "endpoints": { "invite_bot": "/invite", "setup": "/setup", - "github_auth": "/auth/start/" + "github_auth": "/auth/start/", + "github_app_install": "/github/app/install", + "github_app_setup_callback": "/github/app/setup" } }) @@ -94,8 +100,7 @@ def invite_bot(): f"client_id={bot_client_id}&" f"permissions={permissions}&" f"integration_type=0&" - f"scope=bot+applications.commands&" - f"redirect_uri={base_url}/setup" + f"scope=bot+applications.commands" ) # Enhanced landing page with clear instructions @@ -153,16 +158,16 @@ def invite_bot():
Step 1: Click "Add Bot to Discord" above
-
- Step 2: After adding the bot, visit this setup URL: -
{base_url}/setup
-
-
- Step 3: Enter your GitHub organization name (e.g. "your-org") -
-
+
+ Step 2: After adding the bot, visit this setup URL: +
{base_url}/setup
+
+
+ Step 3: Install the GitHub App and select repositories +
+
Step 4: Users can link GitHub accounts with /link in Discord -
+

Features:

@@ -202,6 +207,7 @@ def start_oauth(discord_user_id): # Store user ID in session for callback session['discord_user_id'] = discord_user_id + session['oauth_flow'] = 'link' print(f"Starting OAuth for Discord user: {discord_user_id}") @@ -214,13 +220,13 @@ def start_oauth(discord_user_id): @app.route("/auth/callback") def github_callback(): - """Handle GitHub OAuth callback - original working version""" + """Handle GitHub OAuth callback for user account linking.""" try: discord_user_id = session.get('discord_user_id') if not discord_user_id: return "Authentication failed: No Discord user session", 400 - + if not github.authorized: print("GitHub OAuth not authorized") with oauth_sessions_lock: @@ -229,8 +235,7 @@ def github_callback(): 'error': 'GitHub authorization failed' } return "GitHub authorization failed", 400 - - # Get GitHub user info + resp = github.get("/user") if not resp.ok: print(f"GitHub API call failed: {resp.status_code}") @@ -240,10 +245,10 @@ def github_callback(): 'error': 'Failed to fetch GitHub user info' } return "Failed to fetch GitHub user information", 400 - + github_user = resp.json() github_username = github_user.get("login") - + if not github_username: print("No GitHub username found") with oauth_sessions_lock: @@ -252,17 +257,19 @@ def github_callback(): 'error': 'No GitHub username found' } return "Failed to get GitHub username", 400 - - # Store successful result + with oauth_sessions_lock: oauth_sessions[discord_user_id] = { 'status': 'completed', 'github_username': github_username, 'github_user_data': github_user } - + + session.pop('oauth_flow', None) + session.pop('discord_user_id', None) + print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") - + return f""" Authentication Successful @@ -279,23 +286,153 @@ def github_callback(): """ - + except Exception as e: print(f"Error in OAuth callback: {e}") return f"Authentication failed: {str(e)}", 500 - + + @app.route("/github/app/install") + def github_app_install(): + """Redirect server owners to install the DisgitBot GitHub App.""" + from flask import request + + guild_id = request.args.get('guild_id') + guild_name = request.args.get('guild_name', 'your server') + + if not guild_id: + return "Error: No Discord server information received", 400 + + app_slug = os.getenv("GITHUB_APP_SLUG") + if not app_slug: + return "Server configuration error: missing GITHUB_APP_SLUG", 500 + + 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}" + return redirect(install_url) + + @app.route("/github/app/setup") + 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 + from src.services.github_app_service import GitHubAppService + + installation_id = request.args.get('installation_id') + state = request.args.get('state', '') + + if not installation_id or not state: + return "Missing installation_id or state", 400 + + try: + payload = state_serializer.loads(state, max_age=60 * 30) + except SignatureExpired: + return "Setup link expired. Please restart setup from Discord.", 400 + except BadSignature: + return "Invalid setup state. Please restart setup from Discord.", 400 + + guild_id = str(payload.get('guild_id', '')) + guild_name = payload.get('guild_name', 'your server') + if not guild_id: + return "Invalid setup state (missing guild_id). Please restart setup from Discord.", 400 + + gh_app = GitHubAppService() + installation = gh_app.get_installation(int(installation_id)) + if not installation: + return "Failed to fetch installation details from GitHub.", 500 + + account = installation.get('account') or {} + github_account = account.get('login') + github_account_type = account.get('type') + + github_org = github_account + is_personal_install = github_account_type == 'User' + + mt_client = get_mt_client() + success = mt_client.set_server_config(guild_id, { + 'github_org': github_org, + 'github_installation_id': int(installation_id), + 'github_account': github_account, + 'github_account_type': github_account_type, + 'setup_source': 'github_app', + 'created_at': datetime.now().isoformat(), + 'setup_completed': True + }) + + if not success: + return "Error: Failed to save configuration", 500 + + success_page = """ + + + + GitHub Connected! + + + + + +
+

GitHub Connected!

+

{{ guild_name }} is now connected to GitHub {{ github_org }}.

+ {% if is_personal_install %} +

+ Heads up: you installed the app on a personal account. If you need org repos, + reinstall the app on your organization. +

+ {% endif %} + +

Next Steps in Discord

+

1) Users link their GitHub accounts:

+
/link
+

2) Configure custom roles:

+
/configure roles
+

3) Try these commands:

+
/getstats
+
/halloffame
+
+ + + """ + + return render_template_string( + success_page, + guild_name=guild_name, + github_org=github_org, + is_personal_install=is_personal_install + ) + @app.route("/setup") def setup(): """Setup page after Discord bot is added to server""" from flask import request, render_template_string - + from urllib.parse import urlencode + # Get Discord server info from OAuth callback guild_id = request.args.get('guild_id') guild_name = request.args.get('guild_name', 'your server') - + if not guild_id: return "Error: No Discord server information received", 400 - + + github_app_install_url = f"{base_url}/github/app/install?{urlencode({'guild_id': guild_id, 'guild_name': guild_name})}" + setup_page = """ @@ -304,57 +441,51 @@ def setup():

DisgitBot Added Successfully!

Bot has been added to {{ guild_name }}

- -
- - -
- - -
- Enter the GitHub organization name you want to track.
- This is the name that appears in GitHub URLs: github.com/your-org/repo-name -
-
- - -
- + +

Recommended: Install the GitHub App

+

Install the DisgitBot GitHub App and pick which repositories to track.

+ Install GitHub App + +

Manual Setup (disabled)

+

+ Manual setup is disabled in the hosted version. Please use + Install GitHub App above to connect your repositories. +

+

After setup, users can link their GitHub accounts using /link in Discord.

@@ -362,8 +493,13 @@ def setup(): """ - - return render_template_string(setup_page, guild_id=guild_id, guild_name=guild_name) + + return render_template_string( + setup_page, + guild_id=guild_id, + guild_name=guild_name, + github_app_install_url=github_app_install_url + ) @app.route("/complete_setup", methods=["POST"]) def complete_setup(): @@ -373,7 +509,10 @@ def complete_setup(): from datetime import datetime guild_id = request.form.get('guild_id') - github_org = request.form.get('github_org', '').strip() + selected_org = request.form.get('github_org', '').strip() + manual_org = request.form.get('manual_org', '').strip() + github_org = manual_org or selected_org + setup_source = request.form.get('setup_source', 'manual').strip() or 'manual' if not guild_id or not github_org: return "Error: Missing required information", 400 @@ -387,6 +526,7 @@ def complete_setup(): mt_client = get_mt_client() success = mt_client.set_server_config(guild_id, { 'github_org': github_org, + 'setup_source': setup_source, 'created_at': datetime.now().isoformat(), 'setup_completed': True }) @@ -394,12 +534,6 @@ def complete_setup(): if not success: return "Error: Failed to save configuration", 500 - # Trigger initial data collection for this organization - try: - trigger_data_pipeline_for_org(github_org) - except Exception as e: - print(f"Warning: Failed to trigger initial data collection: {e}") - # Don't fail setup if pipeline trigger fails success_page = """ @@ -493,38 +627,3 @@ def wait_for_username(discord_user_id, max_wait_time=300): del oauth_sessions[discord_user_id] return None - -def trigger_data_pipeline_for_org(github_org): - """Trigger the GitHub Actions workflow to collect data for a specific organization.""" - import requests - - # GitHub API endpoint for triggering workflow_dispatch - repo_owner = os.getenv('REPO_OWNER', 'ruxailab') - repo_name = "disgitbot" - workflow_id = "discord_bot_pipeline.yml" - - url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/{workflow_id}/dispatches" - - headers = { - "Authorization": f"token {os.getenv('GITHUB_TOKEN')}", - "Accept": "application/vnd.github.v3+json" - } - - payload = { - "ref": "main", - "inputs": { - "organization": github_org - } - } - - try: - response = requests.post(url, headers=headers, json=payload) - if response.status_code == 204: - print(f"Successfully triggered data pipeline for {github_org}") - return True - else: - print(f"Failed to trigger pipeline for {github_org}. Status: {response.status_code}") - return False - except Exception as e: - print(f"Error triggering pipeline for {github_org}: {e}") - return False diff --git a/discord_bot/src/services/github_app_service.py b/discord_bot/src/services/github_app_service.py new file mode 100644 index 0000000..a9775cb --- /dev/null +++ b/discord_bot/src/services/github_app_service.py @@ -0,0 +1,89 @@ +import base64 +import os +import time +from typing import Any, Dict, Optional + +import requests + + +class GitHubAppService: + """GitHub App authentication helpers (JWT + installation access tokens).""" + + def __init__(self): + self.api_url = "https://api.github.com" + self.app_id = os.getenv("GITHUB_APP_ID") + self._private_key_pem = self._load_private_key_pem() + + self._jwt_token: Optional[str] = None + self._jwt_exp: int = 0 + + if not self.app_id: + raise ValueError("GITHUB_APP_ID environment variable is required for GitHub App auth") + if not self._private_key_pem: + raise ValueError("GITHUB_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY_B64) is required for GitHub App auth") + + def _load_private_key_pem(self) -> str: + key = os.getenv("GITHUB_APP_PRIVATE_KEY", "") + if key: + return key.replace("\\n", "\n") + + key_b64 = os.getenv("GITHUB_APP_PRIVATE_KEY_B64", "") + if key_b64: + return base64.b64decode(key_b64).decode("utf-8") + + return "" + + def get_app_jwt(self) -> str: + """Create (or reuse) an app JWT.""" + now = int(time.time()) + if self._jwt_token and now < (self._jwt_exp - 60): + return self._jwt_token + + try: + import jwt # PyJWT + except Exception as e: + raise RuntimeError("PyJWT is required for GitHub App auth. Install PyJWT[crypto].") from e + + payload = { + "iat": now - 60, + "exp": now + 9 * 60, + "iss": self.app_id, + } + token = jwt.encode(payload, self._private_key_pem, algorithm="RS256") + self._jwt_token = token + self._jwt_exp = payload["exp"] + return token + + def _app_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.get_app_jwt()}", + "Accept": "application/vnd.github+json", + } + + def get_installation(self, installation_id: int) -> Optional[Dict[str, Any]]: + """Fetch installation metadata (account login/type).""" + try: + url = f"{self.api_url}/app/installations/{installation_id}" + resp = requests.get(url, headers=self._app_headers(), timeout=30) + if resp.status_code != 200: + print(f"Failed to fetch installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + return resp.json() + except Exception as e: + print(f"Error fetching installation {installation_id}: {e}") + return None + + def get_installation_access_token(self, installation_id: int) -> Optional[str]: + """Create a short-lived installation access token.""" + try: + url = f"{self.api_url}/app/installations/{installation_id}/access_tokens" + resp = requests.post(url, headers=self._app_headers(), json={}, timeout=30) + if resp.status_code != 201: + print(f"Failed to create access token for installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + data = resp.json() + return data.get("token") + except Exception as e: + print(f"Error creating access token for installation {installation_id}: {e}") + return None + diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index 453b2ca..2500211 100644 --- a/discord_bot/src/services/github_service.py +++ b/discord_bot/src/services/github_service.py @@ -13,18 +13,18 @@ class GitHubService: """GitHub API service for data collection.""" - def __init__(self, repo_owner: str = None): + def __init__(self, repo_owner: str = None, token: Optional[str] = None, installation_id: Optional[int] = None): self.api_url = "https://api.github.com" - self.token = os.getenv('GITHUB_TOKEN') + self.token = token or os.getenv('GITHUB_TOKEN') self.repo_owner = repo_owner or os.getenv('REPO_OWNER', 'ruxailab') - - if not self.token: - raise ValueError("GITHUB_TOKEN environment variable is required") + self.installation_id = installation_id self._request_count = 0 def _get_headers(self) -> Dict[str, str]: """Get GitHub API headers with authentication.""" + if not self.token: + raise ValueError("GitHub token is required for API access") return { "Authorization": f"token {self.token}", "Accept": "application/vnd.github.v3+json" @@ -193,7 +193,8 @@ def _paginate_list_results(self, base_url: str, rate_type: str = 'core') -> List print(f"DEBUG - Starting list pagination for: {base_url}") while True: - paginated_url = f"{base_url}?per_page={per_page}&page={page}" + joiner = "&" if "?" in base_url else "?" + paginated_url = f"{base_url}{joiner}per_page={per_page}&page={page}" response = self._make_request(paginated_url, rate_type) if not response or response.status_code != 200: @@ -237,6 +238,48 @@ def fetch_repository_labels(self, owner: str, repo: str) -> List[Dict[str, Any]] labels_url = f"{self.api_url}/repos/{owner}/{repo}/labels" return self._paginate_list_results(labels_url, 'core') + def fetch_installation_repositories(self) -> List[Dict[str, str]]: + """Fetch repositories available to the current installation token.""" + if not self.installation_id: + return [] + + try: + repos_url = f"{self.api_url}/installation/repositories" + all_repos: List[Dict[str, str]] = [] + page = 1 + per_page = 100 + + while True: + url = f"{repos_url}?per_page={per_page}&page={page}" + response = self._make_request(url, 'core') + + if not response or response.status_code != 200: + print(f"Failed to fetch installation repositories at page {page}") + break + + data = response.json() or {} + repos_data = data.get('repositories', []) or [] + if not repos_data: + break + + for repo in repos_data: + owner = (repo.get('owner') or {}).get('login') + name = repo.get('name') + if owner and name: + all_repos.append({'name': name, 'owner': owner}) + + total = data.get('total_count', len(all_repos)) + if len(repos_data) < per_page or len(all_repos) >= total: + break + + page += 1 + + print(f"Found {len(all_repos)} repositories for installation") + return all_repos + except Exception as e: + print(f"Error fetching installation repositories: {e}") + return [] + def fetch_organization_repositories(self) -> List[Dict[str, str]]: """Fetch all repositories for the organization.""" try: @@ -255,6 +298,14 @@ def fetch_organization_repositories(self) -> List[Dict[str, str]]: except Exception as e: print(f"Error fetching repositories: {e}") return [] + + def fetch_accessible_repositories(self) -> List[Dict[str, str]]: + """Fetch repositories accessible by this token (installation or org token).""" + if self.installation_id: + repos = self.fetch_installation_repositories() + if repos: + 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.""" @@ -316,7 +367,7 @@ def collect_complete_repository_data(self, owner: str, repo: str) -> Dict[str, A return repo_data def collect_organization_data(self) -> Dict[str, Any]: - """Collect complete data for all repositories in the organization.""" + """Collect complete data for all repositories accessible by this token.""" print("========== Collecting Organization Data ==========") # Validate GitHub token @@ -332,7 +383,7 @@ def collect_organization_data(self) -> Dict[str, Any]: print("WARNING: Unable to check initial rate limits") # Fetch all repositories - repos = self.fetch_organization_repositories() + repos = self.fetch_accessible_repositories() # Collect data for each repository all_data = { @@ -356,4 +407,4 @@ def collect_organization_data(self) -> Dict[str, Any]: all_data['total_api_requests'] = self._request_count print(f"DEBUG - Total API requests made: {self._request_count}") - return all_data \ No newline at end of file + return all_data diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 6963535..62e5513 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -36,8 +36,9 @@ 'description': 'Discord bot token for authentication' }, 'GITHUB_TOKEN': { - 'required': True, - 'description': 'GitHub personal access token for API access' + 'required': False, + 'warning_if_empty': 'GITHUB_TOKEN is optional when using a GitHub App; required only for legacy PAT-based features like workflow dispatch.', + 'description': 'GitHub personal access token for legacy API access' }, 'GITHUB_CLIENT_ID': { 'required': True, @@ -55,6 +56,25 @@ 'required': False, 'warning_if_empty': "OAUTH_BASE_URL is empty - if you're deploying to get an initial URL, this is OK. You can update it later after deployment.", 'description': 'Base URL for OAuth redirects (auto-detected on Cloud Run if empty)' + }, + 'DISCORD_BOT_CLIENT_ID': { + 'required': True, + 'description': 'Discord application ID (client ID)' + }, + 'GITHUB_APP_ID': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_ID is optional for legacy OAuth/PAT mode; required for the invite-only GitHub App installation flow.', + 'description': 'GitHub App ID (for GitHub App auth)' + }, + 'GITHUB_APP_PRIVATE_KEY_B64': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_PRIVATE_KEY_B64 is required for GitHub App auth unless GITHUB_APP_PRIVATE_KEY is provided.', + 'description': 'Base64-encoded GitHub App private key PEM' + }, + 'GITHUB_APP_SLUG': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_SLUG is required to generate the GitHub App install URL in /setup.', + 'description': 'GitHub App slug (the /apps/ part)' } } @@ -422,4 +442,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() From aaf04e6332935d787db71acc84d6a84bedecfe26 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 26 Dec 2025 20:59:20 +0700 Subject: [PATCH 18/30] feat: add role config and setup/link behavior --- discord_bot/src/bot/bot.py | 18 +- discord_bot/src/bot/commands/__init__.py | 3 +- .../src/bot/commands/admin_commands.py | 27 ++- .../src/bot/commands/config_commands.py | 171 ++++++++++++++++++ discord_bot/src/bot/commands/user_commands.py | 77 ++++---- discord_bot/src/services/guild_service.py | 88 +++++++-- discord_bot/src/services/role_service.py | 21 ++- 7 files changed, 335 insertions(+), 70 deletions(-) create mode 100644 discord_bot/src/bot/commands/config_commands.py diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index bd8288e..d85da74 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -10,7 +10,7 @@ from discord.ext import commands from dotenv import load_dotenv -from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands +from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands, ConfigCommands class DiscordBot: """Main Discord bot class with modular command registration.""" @@ -76,7 +76,8 @@ async def on_guild_join(guild): if system_channel: base_url = os.getenv("OAUTH_BASE_URL") - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + from urllib.parse import urlencode + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""🎉 **DisgitBot Added Successfully!** @@ -84,8 +85,9 @@ async def on_guild_join(guild): **Quick Setup (30 seconds):** 1. Visit: {setup_url} -2. Enter your GitHub organization name +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` @@ -124,7 +126,8 @@ async def notify_unconfigured_servers(): if system_channel: base_url = os.getenv("OAUTH_BASE_URL") - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + from urllib.parse import urlencode + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""⚠️ **DisgitBot Setup Required** @@ -132,8 +135,9 @@ async def notify_unconfigured_servers(): **Quick Setup (30 seconds):** 1. Visit: {setup_url} -2. Enter your GitHub organization name +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` @@ -156,11 +160,13 @@ def _register_commands(self): admin_commands = AdminCommands(self.bot) analytics_commands = AnalyticsCommands(self.bot) notification_commands = NotificationCommands(self.bot) + config_commands = ConfigCommands(self.bot) user_commands.register_commands() admin_commands.register_commands() analytics_commands.register_commands() notification_commands.register_commands() + config_commands.register_commands() print("All command modules registered") @@ -172,4 +178,4 @@ def run(self): def create_bot(): """Factory function to create Discord bot instance.""" - return DiscordBot() \ No newline at end of file + return DiscordBot() diff --git a/discord_bot/src/bot/commands/__init__.py b/discord_bot/src/bot/commands/__init__.py index 497a507..393f1f8 100644 --- a/discord_bot/src/bot/commands/__init__.py +++ b/discord_bot/src/bot/commands/__init__.py @@ -8,5 +8,6 @@ from .admin_commands import AdminCommands from .analytics_commands import AnalyticsCommands from .notification_commands import NotificationCommands +from .config_commands import ConfigCommands -__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands'] \ No newline at end of file +__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands', 'ConfigCommands'] diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index bff09e0..11a99a5 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -53,7 +53,7 @@ async def check_permissions(interaction: discord.Interaction): def _setup_command(self): """Create the setup command for server configuration.""" - @app_commands.command(name="setup", description="Get setup link to configure GitHub organization") + @app_commands.command(name="setup", description="Get setup link to connect GitHub organization") async def setup(interaction: discord.Interaction): """Provides setup link for server administrators.""" await interaction.response.defer(ephemeral=True) @@ -67,23 +67,40 @@ async def setup(interaction: discord.Interaction): guild = interaction.guild assert guild is not None, "Command should only work in guilds" + # Check existing configuration + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) or {} + if server_config.get('setup_completed'): + github_org = server_config.get('github_org', 'unknown') + await interaction.followup.send( + f"✅ This server is already configured.\n\n" + f"GitHub org/account: `{github_org}`\n" + f"Users can run `/link` to connect their accounts.\n" + f"Admins can adjust roles with `/configure roles`.", + ephemeral=True + ) + return + # Get the base URL from environment import os + from urllib.parse import urlencode base_url = os.getenv("OAUTH_BASE_URL") if not base_url: await interaction.followup.send("Bot configuration error - please contact support.", ephemeral=True) return - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""**🔧 DisgitBot Setup Required** -Your server needs to be configured to track a GitHub organization. +Your server needs to connect a GitHub organization. **Steps:** 1. Visit: {setup_url} -2. Enter your GitHub organization name (e.g. "your-org") +2. Install the GitHub App and select repositories 3. Users can then link accounts with `/link` +4. Configure roles with `/configure roles` **Current Status:** ❌ Not configured **After Setup:** ✅ Ready to track contributions @@ -300,4 +317,4 @@ async def list_reviewers(interaction: discord.Interaction): import traceback traceback.print_exc() - return list_reviewers \ No newline at end of file + return list_reviewers diff --git a/discord_bot/src/bot/commands/config_commands.py b/discord_bot/src/bot/commands/config_commands.py new file mode 100644 index 0000000..b5d1491 --- /dev/null +++ b/discord_bot/src/bot/commands/config_commands.py @@ -0,0 +1,171 @@ +""" +Configuration Commands Module + +Server configuration commands for role mappings and setup checks. +""" + +import discord +from discord import app_commands +from shared.firestore import get_mt_client + + +class ConfigCommands: + """Handles configuration commands for server administrators.""" + + def __init__(self, bot): + self.bot = bot + + def register_commands(self): + """Register configuration commands with the bot.""" + configure_group = app_commands.Group( + name="configure", + description="Configure DisgitBot settings for this server" + ) + + @configure_group.command( + name="roles", + description="Manage custom role mappings by contributions" + ) + @app_commands.describe( + action="Choose an action", + metric="Contribution type to map", + threshold="Minimum count required", + role="Discord role to grant" + ) + @app_commands.choices( + action=[ + app_commands.Choice(name="list", value="list"), + app_commands.Choice(name="add", value="add"), + app_commands.Choice(name="remove", value="remove"), + app_commands.Choice(name="reset", value="reset"), + ], + metric=[ + app_commands.Choice(name="prs", value="pr"), + app_commands.Choice(name="issues", value="issue"), + app_commands.Choice(name="commits", value="commit"), + ] + ) + async def configure_roles( + interaction: discord.Interaction, + action: app_commands.Choice[str], + metric: app_commands.Choice[str] | None = None, + threshold: int | None = None, + role: discord.Role | None = None + ): + await interaction.response.defer(ephemeral=True) + + if not interaction.user.guild_permissions.administrator: + await interaction.followup.send("Only server administrators can configure roles.", ephemeral=True) + return + + guild = interaction.guild + if not guild: + await interaction.followup.send("This command can only be used in a server.", ephemeral=True) + return + + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) or {} + if not server_config.get('setup_completed'): + await interaction.followup.send("Run `/setup` first to connect GitHub.", ephemeral=True) + return + + role_rules = server_config.get('role_rules') or { + 'pr': [], + 'issue': [], + 'commit': [] + } + + action_value = action.value + + if action_value == "list": + await interaction.followup.send(self._format_role_rules(role_rules), ephemeral=True) + return + + if action_value == "reset": + role_rules = {'pr': [], 'issue': [], 'commit': []} + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + await interaction.followup.send("Role rules reset to defaults.", ephemeral=True) + return + + if action_value == "add": + if not metric or threshold is None or not role: + await interaction.followup.send( + "Usage: `/configure roles action:add metric: threshold: role:@Role`", + ephemeral=True + ) + return + + if threshold <= 0: + await interaction.followup.send("Threshold must be a positive number.", ephemeral=True) + return + + metric_key = metric.value + rules = role_rules.get(metric_key, []) + + # Remove existing rule for this role to avoid duplicates + rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + + rules.append({ + 'threshold': int(threshold), + 'role_id': str(role.id), + 'role_name': role.name + }) + + rules = sorted(rules, key=lambda r: r.get('threshold', 0)) + role_rules[metric_key] = rules + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send( + f"Added rule: {metric.name} {threshold}+ -> @{role.name}", + ephemeral=True + ) + return + + if action_value == "remove": + if not role: + await interaction.followup.send( + "Usage: `/configure roles action:remove role:@Role`", + ephemeral=True + ) + return + + removed = False + for key in ('pr', 'issue', 'commit'): + rules = role_rules.get(key, []) + new_rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + if len(new_rules) != len(rules): + removed = True + role_rules[key] = new_rules + + if not removed: + await interaction.followup.send("That role is not in your custom rules.", ephemeral=True) + return + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send(f"Removed custom rules for @{role.name}.", ephemeral=True) + return + + await interaction.followup.send("Unknown action. Use list, add, remove, or reset.", ephemeral=True) + + self.bot.tree.add_command(configure_group) + + def _format_role_rules(self, role_rules: dict) -> str: + sections = [] + for key, label in (('pr', 'PRs'), ('issue', 'Issues'), ('commit', 'Commits')): + rules = role_rules.get(key, []) + if not rules: + sections.append(f"{label}: (no custom rules)") + continue + lines = [f"{label}:"] + for rule in sorted(rules, key=lambda r: r.get('threshold', 0)): + threshold = rule.get('threshold', 0) + role_name = rule.get('role_name', 'Unknown') + lines.append(f" - {threshold}+ -> @{role_name}") + sections.append("\n".join(lines)) + + return "Custom role rules:\n" + "\n\n".join(sections) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 9d6c329..19298ab 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -22,10 +22,16 @@ def __init__(self, bot): async def _safe_defer(self, interaction): """Safely defer interaction with error handling.""" try: + if interaction.response.is_done(): + return await interaction.response.defer(ephemeral=True) except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass + except discord.errors.HTTPException as exc: + if exc.code == 40060: + return + raise async def _safe_followup(self, interaction, message, embed=False): """Safely send followup message with error handling.""" @@ -37,6 +43,10 @@ async def _safe_followup(self, interaction, message, embed=False): except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass + except discord.errors.HTTPException as exc: + if exc.code == 40060: + return + raise def register_commands(self): """Register all user commands with the bot.""" @@ -57,6 +67,25 @@ async def link(interaction: discord.Interaction): try: discord_user_id = str(interaction.user.id) + discord_server_id = str(interaction.guild.id) + mt_client = get_mt_client() + + existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} + existing_github = existing_user_data.get('github_id') + existing_servers = existing_user_data.get('servers', []) + + if existing_github: + if discord_server_id not in existing_servers: + existing_servers.append(discord_server_id) + existing_user_data['servers'] = existing_servers + mt_client.set_user_mapping(discord_user_id, existing_user_data) + + await self._safe_followup( + interaction, + f"✅ Already linked to GitHub user: `{existing_github}`\n" + f"Use `/unlink` to disconnect and relink." + ) + return oauth_url = get_github_username_for_user(discord_user_id) await self._safe_followup(interaction, f"Please complete GitHub authentication: {oauth_url}") @@ -66,12 +95,6 @@ async def link(interaction: discord.Interaction): ) if github_username: - discord_server_id = str(interaction.guild.id) - - # Get existing user data or create new - from shared.firestore import get_mt_client - mt_client = get_mt_client() - existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} # Add this server to user's server list servers_list = existing_user_data.get('servers', []) @@ -92,10 +115,11 @@ async def link(interaction: discord.Interaction): mt_client.set_user_mapping(discord_user_id, user_data) - # Trigger the data pipeline to collect stats for the new user - await self._trigger_data_pipeline() - - await self._safe_followup(interaction, f"Successfully linked to GitHub user: `{github_username}`\n Your stats will be available within a few minutes!") + await self._safe_followup( + interaction, + f"Successfully linked to GitHub user: `{github_username}`\n" + f"Stats and roles update on the next sync cycle." + ) else: await self._safe_followup(interaction, "Authentication timed out or failed. Please try again.") @@ -351,36 +375,3 @@ def _create_halloffame_embed(self, top_3, type, period, last_updated): embed.set_footer(text=f"Last updated: {last_updated or 'Unknown'}") return embed - async def _trigger_data_pipeline(self): - """Trigger the GitHub Actions workflow to collect data for the new user.""" - import aiohttp - import os - - # GitHub API endpoint for triggering workflow_dispatch - repo_owner = os.getenv('REPO_OWNER', 'ruxailab') - repo_name = "disgitbot" - workflow_id = "discord_bot_pipeline.yml" - - url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/{workflow_id}/dispatches" - - headers = { - "Authorization": f"token {os.getenv('GITHUB_TOKEN')}", - "Accept": "application/vnd.github.v3+json" - } - - payload = { - "ref": "main" - } - - try: - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=payload) as response: - if response.status == 204: - print("Successfully triggered data pipeline") - return True - else: - print(f"Failed to trigger pipeline. Status: {response.status}") - return False - except Exception as e: - print(f"Error triggering pipeline: {e}") - return False diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index 2c73650..41a3f03 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -30,6 +30,7 @@ async def update_roles_and_channels(self, discord_server_id: str, user_mappings: 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 @@ -49,7 +50,13 @@ async def on_ready(): print(f"Processing guild: {guild.name} (ID: {guild.id})") # Update roles with organization-specific data - updated_count = await self._update_roles_for_guild(guild, user_mappings, contributions, github_org) + updated_count = await self._update_roles_for_guild( + guild, + user_mappings, + contributions, + github_org, + role_rules or {} + ) print(f"Updated {updated_count} members in {guild.name}") # Update channels @@ -78,7 +85,14 @@ async def on_ready(): traceback.print_exc() return False - async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dict[str, str], contributions: Dict[str, Any], github_org: str) -> int: + async def _update_roles_for_guild( + self, + guild: discord.Guild, + user_mappings: Dict[str, str], + contributions: Dict[str, Any], + github_org: str, + role_rules: Dict[str, Any] + ) -> int: """Update roles for a single guild using role service.""" if not self._role_service: print("Role service not available - skipping role updates") @@ -93,6 +107,22 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic obsolete_roles = self._role_service.get_obsolete_role_names() current_roles = set(self._role_service.get_all_role_names()) existing_roles = {role.name: role for role in guild.roles} + existing_roles_by_id = {role.id: role for role in guild.roles} + + custom_role_ids = set() + custom_role_names = set() + for rules in role_rules.values(): + if not isinstance(rules, list): + continue + for rule in rules: + role_id = str(rule.get('role_id', '')).strip() + role_name = str(rule.get('role_name', '')).strip() + if role_id.isdigit(): + custom_role_ids.add(int(role_id)) + if role_name: + custom_role_names.add(role_name) + + managed_role_names = current_roles | custom_role_names # Remove obsolete roles from server for role_name in obsolete_roles: @@ -119,6 +149,19 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic except Exception as e: print(f"Error creating role {role_name}: {e}") + def resolve_custom_role(rule: Dict[str, Any]): + if not rule: + return None + role_id = str(rule.get('role_id', '')).strip() + if role_id.isdigit(): + role_obj = existing_roles_by_id.get(int(role_id)) + if role_obj: + return role_obj + role_name = str(rule.get('role_name', '')).strip() + if role_name: + return existing_roles.get(role_name) + return None + # Update users updated_count = 0 for member in guild.members: @@ -133,26 +176,43 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic # Get correct roles for user pr_role, issue_role, commit_role = self._role_service.determine_roles(pr_count, issues_count, commits_count) - correct_roles = {pr_role, issue_role, commit_role} + custom_roles = self._role_service.determine_custom_roles(pr_count, issues_count, commits_count, role_rules) + + pr_role_obj = resolve_custom_role(custom_roles.get('pr')) or existing_roles.get(pr_role) + issue_role_obj = resolve_custom_role(custom_roles.get('issue')) or existing_roles.get(issue_role) + commit_role_obj = resolve_custom_role(custom_roles.get('commit')) or existing_roles.get(commit_role) + + correct_role_objs = [] + for role_obj in (pr_role_obj, issue_role_obj, commit_role_obj): + if role_obj and role_obj not in correct_role_objs: + correct_role_objs.append(role_obj) + if github_username in medal_assignments: - correct_roles.add(medal_assignments[github_username]) - correct_roles.discard(None) - + medal_role_name = medal_assignments[github_username] + medal_role_obj = existing_roles.get(medal_role_name) + if medal_role_obj and medal_role_obj not in correct_role_objs: + correct_role_objs.append(medal_role_obj) + + correct_role_ids = {role.id for role in correct_role_objs} + # Remove obsolete roles and roles user outgrew - user_bot_roles = [role for role in member.roles if role.name in (obsolete_roles | current_roles)] - roles_to_remove = [role for role in user_bot_roles if role.name not in correct_roles] + user_bot_roles = [ + role for role in member.roles + if role.name in (obsolete_roles | managed_role_names) or role.id in custom_role_ids + ] + roles_to_remove = [role for role in user_bot_roles if role.id not in correct_role_ids] if roles_to_remove: await member.remove_roles(*roles_to_remove) print(f"Removed {[r.name for r in roles_to_remove]} from {member.name}") # Add missing roles - for role_name in correct_roles: - if role_name in roles and roles[role_name] not in member.roles: - await member.add_roles(roles[role_name]) - print(f"Added {role_name} to {member.name}") + for role_obj in correct_role_objs: + if role_obj not in member.roles: + await member.add_roles(role_obj) + print(f"Added {role_obj.name} to {member.name}") - if roles_to_remove or any(role_name in roles and roles[role_name] not in member.roles for role_name in correct_roles): + if roles_to_remove or any(role_obj not in member.roles for role_obj in correct_role_objs): updated_count += 1 return updated_count @@ -210,4 +270,4 @@ async def _update_channels_for_guild(self, guild: discord.Guild, metrics: Dict[s except Exception as e: print(f"Error updating channels for guild {guild.name}: {e}") import traceback - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/discord_bot/src/services/role_service.py b/discord_bot/src/services/role_service.py index ab43410..7f72ac7 100644 --- a/discord_bot/src/services/role_service.py +++ b/discord_bot/src/services/role_service.py @@ -107,6 +107,25 @@ def determine_roles(self, pr_count: int, issues_count: int, commits_count: int) commit_role = self._determine_role_for_threshold(commits_count, self.config.commit_thresholds) return pr_role, issue_role, commit_role + + def determine_custom_roles(self, pr_count: int, issues_count: int, commits_count: int, role_rules: Dict[str, Any]) -> Dict[str, Optional[Dict[str, Any]]]: + """Determine custom roles from per-server role rules.""" + return { + 'pr': self._select_custom_rule(pr_count, role_rules.get('pr', [])), + 'issue': self._select_custom_rule(issues_count, role_rules.get('issue', [])), + 'commit': self._select_custom_rule(commits_count, role_rules.get('commit', [])) + } + + def _select_custom_rule(self, count: int, rules: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Pick the highest-threshold custom rule that the count satisfies.""" + if not rules: + return None + sorted_rules = sorted(rules, key=lambda r: r.get('threshold', 0)) + selected = None + for rule in sorted_rules: + if count >= int(rule.get('threshold', 0)): + selected = rule + return selected def _determine_role_for_threshold(self, count: int, thresholds: Dict[str, int]) -> Optional[str]: """Determine role for a specific contribution type.""" @@ -180,4 +199,4 @@ def get_next_role(self, current_role: str, stats_type: str) -> str: next_role = role_list[i + 1][0] return f"@{next_role}" - return "Unknown" \ No newline at end of file + return "Unknown" From a8780136617251d02bf04d8ca09dffe50fc76315 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 26 Dec 2025 20:59:33 +0700 Subject: [PATCH 19/30] docs: add github app envs and setup step stage --- discord_bot/README.md | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/discord_bot/README.md b/discord_bot/README.md index 2d6e39c..962798d 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -1,5 +1,22 @@ # Discord Bot Setup Guide +# Quick Start (Hosted Bot Users) + +Use this section if you only want to invite the hosted bot and use it in your Discord server. + +1. **Invite the bot** using the link provided by the maintainers. +2. In your Discord server, run: `/setup` +3. Click **Install GitHub App** and select the org/repo(s) to track. +4. Each user links their GitHub account with: `/link` +5. (Optional) Configure role rules: + ``` + /configure roles action:add metric:commits threshold:1 role:@Contributor + /configure roles action:add metric:prs threshold:10 role:@ActiveContributor + /configure roles action:add metric:prs threshold:50 role:@CoreTeam + ``` + +That’s it. No local setup, no tokens, no config files. + # 1. Prerequisites ### Python 3.13 Setup @@ -108,6 +125,9 @@ cp discord_bot/config/.env.example discord_bot/config/.env - `GITHUB_TOKEN=` (GitHub API access) - `GITHUB_CLIENT_ID=` (GitHub OAuth app ID) - `GITHUB_CLIENT_SECRET=` (GitHub OAuth app secret) +- `GITHUB_APP_ID=` (GitHub App ID) +- `GITHUB_APP_PRIVATE_KEY_B64=` (GitHub App private key, base64) +- `GITHUB_APP_SLUG=` (GitHub App slug) - `REPO_OWNER=` (Your GitHub organization name) - `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) @@ -121,6 +141,8 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - `GOOGLE_CREDENTIALS_JSON` - `REPO_OWNER` - `CLOUD_RUN_URL` +- `GITHUB_APP_ID` +- `GITHUB_APP_PRIVATE_KEY_B64` If you plan to run GitHub Actions from branches other than `main`, also add the matching development secrets so the workflows can deploy correctly: - `DEV_GOOGLE_CREDENTIALS_JSON` @@ -300,12 +322,40 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **Example URLs:** If your Cloud Run URL is `https://discord-bot-abcd1234-uc.a.run.app`, then: - Homepage URL: `https://discord-bot-abcd1234-uc.a.run.app` - Callback URL: `https://discord-bot-abcd1234-uc.a.run.app/login/github/authorized` + - If you are using the newer hosted flow, set the callback to `YOUR_CLOUD_RUN_URL/auth/callback` instead. 4. **Get Credentials:** - Click "Register application" - Copy the "Client ID" → **Add to `.env`:** `GITHUB_CLIENT_ID=your_client_id` - Click "Generate a new client secret" → Copy it → **Add to `.env`:** `GITHUB_CLIENT_SECRET=your_secret` +### Step 5b: Create GitHub App (GITHUB_APP_ID / PRIVATE_KEY / SLUG) + +**What this configures:** +- `.env` file: `GITHUB_APP_ID=...`, `GITHUB_APP_PRIVATE_KEY_B64=...`, `GITHUB_APP_SLUG=...` +- GitHub Secrets: `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY_B64` + +**What this does:** Allows DisgitBot to read repository data without user PATs. + +1. **Create the GitHub App (org or personal):** + - For org: `https://github.com/organizations//settings/apps` + - For personal: `https://github.com/settings/apps` +2. **Set these URLs:** + - **Homepage URL:** `YOUR_CLOUD_RUN_URL` + - **Setup URL:** `YOUR_CLOUD_RUN_URL/github/app/setup` + - **Callback URL:** leave empty +3. **Permissions (read-only):** + - Metadata (required), Contents, Issues, Pull requests + - Webhooks: OFF +4. **Install target:** choose **Any account** so anyone can install it. +5. **Generate a private key:** + - Download the `.pem` file + - Base64 it (keep BEGIN/END lines): `base64 -w 0 path/to/private-key.pem` +6. **Set `.env` values:** + - `GITHUB_APP_ID=...` (App ID from the GitHub App page) + - `GITHUB_APP_PRIVATE_KEY_B64=...` (base64 from step 5) + - `GITHUB_APP_SLUG=...` (the app slug shown in the app page URL) + ### Step 6: Get REPO_OWNER (.env) + REPO_OWNER (GitHub Secret) **What this configures:** From c79cc1f55924e0ee6a8562927ebc42ccefe57fc6 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 26 Dec 2025 21:23:56 +0700 Subject: [PATCH 20/30] chores: fix discord bot data pipeline actions --- .github/workflows/discord_bot_pipeline.yml | 4 ++-- discord_bot/README.md | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index aec3fcc..5c40889 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -55,8 +55,8 @@ jobs: - name: Collect GitHub Data for Multiple Organizations env: - GITHUB_APP_ID: ${{ secrets.GITHUB_APP_ID }} - GITHUB_APP_PRIVATE_KEY_B64: ${{ secrets.GITHUB_APP_PRIVATE_KEY_B64 }} + GITHUB_APP_ID: ${{ secrets.GH_APP_ID }} + GITHUB_APP_PRIVATE_KEY_B64: ${{ secrets.GH_APP_PRIVATE_KEY_B64 }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | diff --git a/discord_bot/README.md b/discord_bot/README.md index 962798d..cfb1a6b 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -141,8 +141,8 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - `GOOGLE_CREDENTIALS_JSON` - `REPO_OWNER` - `CLOUD_RUN_URL` -- `GITHUB_APP_ID` -- `GITHUB_APP_PRIVATE_KEY_B64` +- `GH_APP_ID` +- `GH_APP_PRIVATE_KEY_B64` If you plan to run GitHub Actions from branches other than `main`, also add the matching development secrets so the workflows can deploy correctly: - `DEV_GOOGLE_CREDENTIALS_JSON` @@ -333,10 +333,15 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **What this configures:** - `.env` file: `GITHUB_APP_ID=...`, `GITHUB_APP_PRIVATE_KEY_B64=...`, `GITHUB_APP_SLUG=...` -- GitHub Secrets: `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY_B64` +- GitHub Secrets: `GH_APP_ID`, `GH_APP_PRIVATE_KEY_B64` **What this does:** Allows DisgitBot to read repository data without user PATs. +**Where these values come from:** +- `GITHUB_APP_ID`: shown on the GitHub App settings page (App ID field). +- `GITHUB_APP_PRIVATE_KEY_B64`: base64 of the downloaded `.pem` private key. +- `GITHUB_APP_SLUG`: the URL slug of your GitHub App (shown in the app page URL). + 1. **Create the GitHub App (org or personal):** - For org: `https://github.com/organizations//settings/apps` - For personal: `https://github.com/settings/apps` @@ -356,6 +361,8 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - `GITHUB_APP_PRIVATE_KEY_B64=...` (base64 from step 5) - `GITHUB_APP_SLUG=...` (the app slug shown in the app page URL) +**Security note:** Never commit the private key or base64 value to git. Treat it like a password. + ### Step 6: Get REPO_OWNER (.env) + REPO_OWNER (GitHub Secret) **What this configures:** From f1f4ff9d6b34ea81942cf8f6f35588bad1605b70 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 27 Dec 2025 00:52:24 +0700 Subject: [PATCH 21/30] fix: /getstats referring to global not per org --- .github/workflows/discord_bot_pipeline.yml | 24 +-- discord_bot/scripts/setup_wizard.py | 159 ++++++++++++++++++ discord_bot/src/bot/commands/user_commands.py | 39 ++--- shared/firestore.py | 34 +++- 4 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 discord_bot/scripts/setup_wizard.py diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 5c40889..9da3440 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -252,20 +252,22 @@ jobs: print(f'Stored labels for {labels_stored} repositories in {github_org}') - # Update user contribution data - user_mappings = {} - for doc in mt_client.db.collection('discord_users').stream(): - user_mappings[doc.id] = doc.to_dict() + # Update org-scoped user contribution data (per Discord server/org) + user_mappings = {doc.id: doc.to_dict() for doc in mt_client.db.collection('discord_users').stream()} stored_count = 0 - for username, user_data in contributions.items(): - # Find Discord users with this GitHub username - for discord_id, user_mapping in user_mappings.items(): - if user_mapping.get('github_id') == username: - if mt_client.set_user_mapping(discord_id, {**user_mapping, **user_data}): - stored_count += 1 + for discord_id, user_mapping in user_mappings.items(): + github_id = user_mapping.get('github_id') + if not github_id: + continue + user_data = contributions.get(github_id) + if not user_data: + continue + org_user_data = {**user_mapping, **user_data} + if mt_client.set_org_document(github_org, 'discord_users', discord_id, org_user_data): + stored_count += 1 - print(f'Updated contribution data for {stored_count} users in {github_org}') + print(f'Updated org-scoped contribution data for {stored_count} users in {github_org}') print('All organization data stored successfully!') " diff --git a/discord_bot/scripts/setup_wizard.py b/discord_bot/scripts/setup_wizard.py new file mode 100644 index 0000000..5a881ef --- /dev/null +++ b/discord_bot/scripts/setup_wizard.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Simple setup wizard for self-hosted DisgitBot. +Generates discord_bot/config/.env from .env.example and optionally copies credentials.json. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +FIELD_DESCRIPTIONS = { + "DISCORD_BOT_TOKEN": "Discord bot token", + "GITHUB_TOKEN": "GitHub personal access token (needs repo read + workflow if using Actions)", + "GITHUB_CLIENT_ID": "GitHub OAuth app client ID", + "GITHUB_CLIENT_SECRET": "GitHub OAuth app client secret", + "REPO_OWNER": "GitHub org/user that owns this repo (for workflow dispatch)", + "OAUTH_BASE_URL": "Public base URL (e.g. https://)", + "DISCORD_BOT_CLIENT_ID": "Discord application ID (client ID)", + "GITHUB_APP_ID": "GitHub App ID (invite-only mode)", + "GITHUB_APP_PRIVATE_KEY_B64": "GitHub App private key (base64 PEM, invite-only mode)", + "GITHUB_APP_SLUG": "GitHub App slug (apps/)", +} + +REQUIRED_KEYS = { + "DISCORD_BOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "REPO_OWNER", + "OAUTH_BASE_URL", + "DISCORD_BOT_CLIENT_ID", +} + + +def _parse_env(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + + values: dict[str, str] = {} + for line in path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def _prompt_value(key: str, existing: str) -> str: + description = FIELD_DESCRIPTIONS.get(key, "") + label = f"{key}" + if description: + label += f" ({description})" + if existing: + label += f" [current: {existing}]" + label += ": " + + value = input(label).strip() + if not value: + return existing + return value + + +def _write_env(example_path: Path, env_path: Path, values: dict[str, str]) -> None: + lines = [] + for line in example_path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#"): + lines.append(line) + continue + if "=" not in line: + lines.append(line) + continue + key, _ = line.split("=", 1) + key = key.strip() + lines.append(f"{key}={values.get(key, '')}") + + env_path.write_text("\n".join(lines) + "\n") + + +def _handle_credentials(config_dir: Path) -> None: + target_path = config_dir / "credentials.json" + if target_path.exists(): + print(f"Found existing credentials at {target_path}") + return + + input_path = input( + "Path to Firebase service account JSON (leave blank to skip): " + ).strip() + if not input_path: + print("Skipping credentials copy. You must add config/credentials.json before running the bot.") + return + + source_path = Path(input_path).expanduser() + if not source_path.exists(): + print(f"File not found: {source_path}") + print("Skipping credentials copy.") + return + + shutil.copy2(source_path, target_path) + print(f"Copied credentials to {target_path}") + + +def main() -> int: + base_dir = Path(__file__).resolve().parents[1] + config_dir = base_dir / "config" + example_path = config_dir / ".env.example" + env_path = config_dir / ".env" + + if not example_path.exists(): + print(f"Missing {example_path}") + return 1 + + config_dir.mkdir(parents=True, exist_ok=True) + + existing_values = _parse_env(env_path) + new_values = dict(existing_values) + + print("DisgitBot setup wizard\n") + + example_keys = [] + for line in example_path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip() + example_keys.append(key) + current = existing_values.get(key, "") + new_values[key] = _prompt_value(key, current) + + # Prompt for any known keys missing from .env.example + for key in FIELD_DESCRIPTIONS: + if key in example_keys: + continue + current = existing_values.get(key, "") + new_values[key] = _prompt_value(key, current) + + missing_required = [ + key for key in REQUIRED_KEYS if not new_values.get(key) + ] + if missing_required: + print("\nMissing required values:") + for key in sorted(missing_required): + print(f"- {key}") + print("\nYou can re-run this wizard after collecting the missing values.") + + _write_env(example_path, env_path, new_values) + print(f"\nWrote {env_path}") + + _handle_credentials(config_dir) + + print("\nNext steps:") + print("- Run: python main.py (from discord_bot/)\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 19298ab..fd93763 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -138,17 +138,19 @@ async def unlink(interaction: discord.Interaction): try: await self._safe_defer(interaction) + discord_user_id = str(interaction.user.id) discord_server_id = str(interaction.guild.id) - user_data = get_document('discord_users', str(interaction.user.id), discord_server_id) - - if user_data: - # Delete document by setting it to empty (Firestore will remove it) - discord_server_id = str(interaction.guild.id) - set_document('discord_users', str(interaction.user.id), {}, discord_server_id=discord_server_id) - await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") - print(f"Unlinked Discord user {interaction.user.name}") - else: + mt_client = get_mt_client() + + user_mapping = mt_client.get_user_mapping(discord_user_id) or {} + if not user_mapping.get('github_id'): await self._safe_followup(interaction, "Your Discord account is not linked to any GitHub username.") + return + + mt_client.set_user_mapping(discord_user_id, {}) + set_document('discord_users', discord_user_id, {}, discord_server_id=discord_server_id) + await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") + print(f"Unlinked Discord user {interaction.user.name}") except Exception as e: print(f"Error unlinking user: {e}") @@ -178,22 +180,17 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): user_id = str(interaction.user.id) - # Get user's Discord data to find their GitHub username + # Check global link mapping first discord_server_id = str(interaction.guild.id) - discord_user_data = get_document('discord_users', user_id, discord_server_id) - if not discord_user_data or not discord_user_data.get('github_id'): + mt_client = get_mt_client() + user_mapping = mt_client.get_user_mapping(user_id) or {} + github_username = user_mapping.get('github_id') + if not github_username: await self._safe_followup(interaction, "Your Discord account is not linked to a GitHub username. Use `/link` to link it.") return - github_username = discord_user_data['github_id'] - - # Use the Discord user data which should contain the full contribution stats - # The pipeline updates Discord documents with full contribution data - user_data = discord_user_data - - if not user_data: - await self._safe_followup(interaction, f"No contribution data found for GitHub user '{github_username}'.") - return + # Fetch org-scoped stats for this server + user_data = get_document('discord_users', user_id, discord_server_id) or {} # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) diff --git a/shared/firestore.py b/shared/firestore.py index a8dafc7..d94456f 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -227,6 +227,14 @@ def get_document(collection: str, document_id: str, discord_server_id: str = Non print(f"No GitHub org found for Discord server: {discord_server_id}") return None return mt_client.get_org_document(github_org, collection, document_id) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return None + return mt_client.get_org_document(github_org, collection, document_id) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -258,6 +266,14 @@ def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: print(f"No GitHub org found for Discord server: {discord_server_id}") return False return mt_client.set_org_document(github_org, collection, document_id, data, merge) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.set_org_document(github_org, collection, document_id, data, merge) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -289,6 +305,14 @@ def update_document(collection: str, document_id: str, data: Dict[str, Any], dis print(f"No GitHub org found for Discord server: {discord_server_id}") return False return mt_client.update_org_document(github_org, collection, document_id, data) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.update_org_document(github_org, collection, document_id, data) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -317,6 +341,14 @@ def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, print(f"No GitHub org found for Discord server: {discord_server_id}") return {} return mt_client.query_org_collection(github_org, collection, filters) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return {} + return mt_client.query_org_collection(github_org, collection, filters) # Handle user mappings (old 'discord' collection) - return all users if collection == 'discord': @@ -345,4 +377,4 @@ def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, return {doc.id: doc.to_dict() for doc in docs} except Exception as e: print(f"Error querying collection {collection}: {e}") - return {} \ No newline at end of file + return {} From 1000f5569eb24ba934689ca86bb6dd835c5f3847 Mon Sep 17 00:00:00 2001 From: Tq Date: Fri, 26 Dec 2025 15:25:08 -0500 Subject: [PATCH 22/30] remove emojis --- discord_bot/src/bot/auth.py | 12 ++--- discord_bot/src/bot/bot.py | 4 +- .../src/bot/commands/admin_commands.py | 8 ++-- discord_bot/src/bot/commands/user_commands.py | 2 +- discord_bot/src/services/role_service.py | 44 +++++++++---------- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 8dac737..6bbbc3b 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -148,13 +148,13 @@ def invite_bot():

Track GitHub contributions and manage roles automatically in your Discord server.

- ⚠️ Important: Setup Required After Adding Bot + Important: Setup Required After Adding Bot
Add Bot to Discord
-

🔧 Setup Instructions (Required)

+

Setup Instructions (Required)

Step 1: Click "Add Bot to Discord" above
@@ -172,16 +172,16 @@ def invite_bot():

Features:

- 📊 Real-time GitHub statistics + Real-time GitHub statistics
- 🏆 Automated role assignment + Automated role assignment
- 📈 Contribution analytics & charts + Contribution analytics & charts
- 🔄 Auto-updating voice channels + Auto-updating voice channels

diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index d85da74..ac3a002 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -79,7 +79,7 @@ async def on_guild_join(guild): from urllib.parse import urlencode setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" - setup_message = f"""🎉 **DisgitBot Added Successfully!** + setup_message = f"""**DisgitBot Added Successfully!** This server needs to be configured to track GitHub contributions. @@ -129,7 +129,7 @@ async def notify_unconfigured_servers(): from urllib.parse import urlencode setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" - setup_message = f"""⚠️ **DisgitBot Setup Required** + setup_message = f"""️ **DisgitBot Setup Required** This server needs to be configured to track GitHub contributions. diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 11a99a5..259308a 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -74,7 +74,7 @@ async def setup(interaction: discord.Interaction): if server_config.get('setup_completed'): github_org = server_config.get('github_org', 'unknown') await interaction.followup.send( - f"✅ This server is already configured.\n\n" + f"This server is already configured.\n\n" f"GitHub org/account: `{github_org}`\n" f"Users can run `/link` to connect their accounts.\n" f"Admins can adjust roles with `/configure roles`.", @@ -92,7 +92,7 @@ async def setup(interaction: discord.Interaction): setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" - setup_message = f"""**🔧 DisgitBot Setup Required** + setup_message = f"""**DisgitBot Setup Required** Your server needs to connect a GitHub organization. @@ -102,8 +102,8 @@ async def setup(interaction: discord.Interaction): 3. Users can then link accounts with `/link` 4. Configure roles with `/configure roles` -**Current Status:** ❌ Not configured -**After Setup:** ✅ Ready to track contributions +**Current Status:** Not configured +**After Setup:** Ready to track contributions This setup is required only once per server.""" diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index fd93763..bbe77e2 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -82,7 +82,7 @@ async def link(interaction: discord.Interaction): await self._safe_followup( interaction, - f"✅ Already linked to GitHub user: `{existing_github}`\n" + f"Already linked to GitHub user: `{existing_github}`\n" f"Use `/unlink` to disconnect and relink." ) return diff --git a/discord_bot/src/services/role_service.py b/discord_bot/src/services/role_service.py index 7f72ac7..f0b682b 100644 --- a/discord_bot/src/services/role_service.py +++ b/discord_bot/src/services/role_service.py @@ -15,28 +15,28 @@ def __init__(self): # PR Role Thresholds self.pr_thresholds = { "🌸 1+ PRs": 1, - "🌺 6+ PRs": 6, - "🌻 16+ PRs": 16, - "🌷 31+ PRs": 31, - "🌹 51+ PRs": 51 + "🌺 5+ PRs": 5, + "🌻 10+ PRs": 10, + "🌷 25+ PRs": 25, + "🌹 50+ PRs": 50 } - - # Issue Role Thresholds + + # Issue Role Thresholds self.issue_thresholds = { "🍃 1+ GitHub Issues Reported": 1, - "🌿 6+ GitHub Issues Reported": 6, - "🌱 16+ GitHub Issues Reported": 16, - "🌾 31+ GitHub Issues Reported": 31, - "🍀 51+ GitHub Issues Reported": 51 + "🌿 5+ GitHub Issues Reported": 5, + "🌱 10+ GitHub Issues Reported": 10, + "🌾 25+ GitHub Issues Reported": 25, + "🍀 50+ GitHub Issues Reported": 50 } - + # Commit Role Thresholds self.commit_thresholds = { "☁️ 1+ Commits": 1, - "🌊 51+ Commits": 51, - "🌈 101+ Commits": 101, - "🌙 251+ Commits": 251, - "⭐ 501+ Commits": 501 + "🌊 25+ Commits": 25, + "🌈 50+ Commits": 50, + "🌙 100+ Commits": 100, + "⭐ 250+ Commits": 250 } # Medal roles for top 3 contributors @@ -44,19 +44,19 @@ def __init__(self): # Obsolete role names to clean up self.obsolete_roles = { - "Beginner (1-5 PRs)", "Contributor (6-15 PRs)", "Analyst (16-30 PRs)", - "Expert (31-50 PRs)", "Master (51+ PRs)", "Beginner (1-5 Issues)", - "Contributor (6-15 Issues)", "Analyst (16-30 Issues)", "Expert (31-50 Issues)", - "Master (51+ Issues)", "Beginner (1-50 Commits)", "Contributor (51-100 Commits)", + "Beginner (1-5 PRs)", "Contributor (6-15 PRs)", "Analyst (16-30 PRs)", + "Expert (31-50 PRs)", "Master (51+ PRs)", "Beginner (1-5 Issues)", + "Contributor (6-15 Issues)", "Analyst (16-30 Issues)", "Expert (31-50 Issues)", + "Master (51+ Issues)", "Beginner (1-50 Commits)", "Contributor (51-100 Commits)", "Analyst (101-250 Commits)", "Expert (251-500 Commits)", "Master (501+ Commits)", - # Clean up the old minimal names + # Old numeric thresholds "1+ PR", "6+ PR", "16+ PR", "31+ PR", "51+ PR", - "1+ Issue", "6+ Issue", "16+ Issue", "31+ Issue", "51+ Issue", + "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", "PR Champion", "PR Runner-up", "PR Bronze", - # Clean up previous emoji versions + # Old emoji versions "🌸 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", From ac7c3a81358af22090e2991728b87a07a82dcaae Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 27 Dec 2025 18:34:34 +0700 Subject: [PATCH 23/30] chores: updated readme & remove unused setup wizard --- discord_bot/README.md | 5 + discord_bot/scripts/setup_wizard.py | 159 ---------------------------- 2 files changed, 5 insertions(+), 159 deletions(-) delete mode 100644 discord_bot/scripts/setup_wizard.py diff --git a/discord_bot/README.md b/discord_bot/README.md index cfb1a6b..88302db 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -17,6 +17,8 @@ Use this section if you only want to invite the hosted bot and use it in your Di That’s it. No local setup, no tokens, no config files. +**Note:** This section is for maintainers (RUXAILAB) or anyone who wants to run/modify the code themselves. If you only want to use the hosted bot, use the **Quick Start (Hosted Bot Users)** section above and skip the prerequisites. + # 1. Prerequisites ### Python 3.13 Setup @@ -349,6 +351,9 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - **Homepage URL:** `YOUR_CLOUD_RUN_URL` - **Setup URL:** `YOUR_CLOUD_RUN_URL/github/app/setup` - **Callback URL:** leave empty +3. **Enable redirect on update (important for multiple Discord servers):** + - Turn on **Redirect on update** so GitHub redirects back to the Setup URL even when the App is already installed. + - This lets a second Discord server complete setup using the same org installation. 3. **Permissions (read-only):** - Metadata (required), Contents, Issues, Pull requests - Webhooks: OFF diff --git a/discord_bot/scripts/setup_wizard.py b/discord_bot/scripts/setup_wizard.py deleted file mode 100644 index 5a881ef..0000000 --- a/discord_bot/scripts/setup_wizard.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple setup wizard for self-hosted DisgitBot. -Generates discord_bot/config/.env from .env.example and optionally copies credentials.json. -""" - -from __future__ import annotations - -import shutil -import sys -from pathlib import Path - -FIELD_DESCRIPTIONS = { - "DISCORD_BOT_TOKEN": "Discord bot token", - "GITHUB_TOKEN": "GitHub personal access token (needs repo read + workflow if using Actions)", - "GITHUB_CLIENT_ID": "GitHub OAuth app client ID", - "GITHUB_CLIENT_SECRET": "GitHub OAuth app client secret", - "REPO_OWNER": "GitHub org/user that owns this repo (for workflow dispatch)", - "OAUTH_BASE_URL": "Public base URL (e.g. https://)", - "DISCORD_BOT_CLIENT_ID": "Discord application ID (client ID)", - "GITHUB_APP_ID": "GitHub App ID (invite-only mode)", - "GITHUB_APP_PRIVATE_KEY_B64": "GitHub App private key (base64 PEM, invite-only mode)", - "GITHUB_APP_SLUG": "GitHub App slug (apps/)", -} - -REQUIRED_KEYS = { - "DISCORD_BOT_TOKEN", - "GITHUB_TOKEN", - "GITHUB_CLIENT_ID", - "GITHUB_CLIENT_SECRET", - "REPO_OWNER", - "OAUTH_BASE_URL", - "DISCORD_BOT_CLIENT_ID", -} - - -def _parse_env(path: Path) -> dict[str, str]: - if not path.exists(): - return {} - - values: dict[str, str] = {} - for line in path.read_text().splitlines(): - if not line.strip() or line.strip().startswith("#"): - continue - if "=" not in line: - continue - key, value = line.split("=", 1) - values[key.strip()] = value.strip() - return values - - -def _prompt_value(key: str, existing: str) -> str: - description = FIELD_DESCRIPTIONS.get(key, "") - label = f"{key}" - if description: - label += f" ({description})" - if existing: - label += f" [current: {existing}]" - label += ": " - - value = input(label).strip() - if not value: - return existing - return value - - -def _write_env(example_path: Path, env_path: Path, values: dict[str, str]) -> None: - lines = [] - for line in example_path.read_text().splitlines(): - if not line.strip() or line.strip().startswith("#"): - lines.append(line) - continue - if "=" not in line: - lines.append(line) - continue - key, _ = line.split("=", 1) - key = key.strip() - lines.append(f"{key}={values.get(key, '')}") - - env_path.write_text("\n".join(lines) + "\n") - - -def _handle_credentials(config_dir: Path) -> None: - target_path = config_dir / "credentials.json" - if target_path.exists(): - print(f"Found existing credentials at {target_path}") - return - - input_path = input( - "Path to Firebase service account JSON (leave blank to skip): " - ).strip() - if not input_path: - print("Skipping credentials copy. You must add config/credentials.json before running the bot.") - return - - source_path = Path(input_path).expanduser() - if not source_path.exists(): - print(f"File not found: {source_path}") - print("Skipping credentials copy.") - return - - shutil.copy2(source_path, target_path) - print(f"Copied credentials to {target_path}") - - -def main() -> int: - base_dir = Path(__file__).resolve().parents[1] - config_dir = base_dir / "config" - example_path = config_dir / ".env.example" - env_path = config_dir / ".env" - - if not example_path.exists(): - print(f"Missing {example_path}") - return 1 - - config_dir.mkdir(parents=True, exist_ok=True) - - existing_values = _parse_env(env_path) - new_values = dict(existing_values) - - print("DisgitBot setup wizard\n") - - example_keys = [] - for line in example_path.read_text().splitlines(): - if not line.strip() or line.strip().startswith("#") or "=" not in line: - continue - key = line.split("=", 1)[0].strip() - example_keys.append(key) - current = existing_values.get(key, "") - new_values[key] = _prompt_value(key, current) - - # Prompt for any known keys missing from .env.example - for key in FIELD_DESCRIPTIONS: - if key in example_keys: - continue - current = existing_values.get(key, "") - new_values[key] = _prompt_value(key, current) - - missing_required = [ - key for key in REQUIRED_KEYS if not new_values.get(key) - ] - if missing_required: - print("\nMissing required values:") - for key in sorted(missing_required): - print(f"- {key}") - print("\nYou can re-run this wizard after collecting the missing values.") - - _write_env(example_path, env_path, new_values) - print(f"\nWrote {env_path}") - - _handle_credentials(config_dir) - - print("\nNext steps:") - print("- Run: python main.py (from discord_bot/)\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 4d85d8d848fbb046a0653dcfda62d0d2a0739946 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 27 Dec 2025 18:47:21 +0700 Subject: [PATCH 24/30] chore: updated readme --- discord_bot/README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/discord_bot/README.md b/discord_bot/README.md index 88302db..90c3496 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -124,15 +124,17 @@ cp discord_bot/config/.env.example discord_bot/config/.env **Your `.env` file needs these values:** - `DISCORD_BOT_TOKEN=` (Discord bot authentication) -- `GITHUB_TOKEN=` (GitHub API access) - `GITHUB_CLIENT_ID=` (GitHub OAuth app ID) - `GITHUB_CLIENT_SECRET=` (GitHub OAuth app secret) - `GITHUB_APP_ID=` (GitHub App ID) - `GITHUB_APP_PRIVATE_KEY_B64=` (GitHub App private key, base64) - `GITHUB_APP_SLUG=` (GitHub App slug) -- `REPO_OWNER=` (Your GitHub organization name) - `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) +**Also required for PR review tooling or legacy single-org flow:** +- `GITHUB_TOKEN=` (PAT for PR review or legacy single-org pipeline) +- `REPO_OWNER=` (Org name for legacy single-org pipeline) + **Additional files you need:** - `discord_bot/config/credentials.json` (Firebase/Google Cloud credentials) @@ -256,7 +258,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - `.env` file: `GITHUB_TOKEN=your_token_here` - GitHub Secret: `GH_TOKEN` -**What this does:** Allows the bot to access GitHub API to fetch repository and contribution data. +**What this does:** Allows PR review tooling and legacy single-org workflows to access the GitHub API. 1. **Go to GitHub Token Settings:** https://github.com/settings/tokens 2. **Create New Token:** @@ -324,7 +326,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **Example URLs:** If your Cloud Run URL is `https://discord-bot-abcd1234-uc.a.run.app`, then: - Homepage URL: `https://discord-bot-abcd1234-uc.a.run.app` - Callback URL: `https://discord-bot-abcd1234-uc.a.run.app/login/github/authorized` - - If you are using the newer hosted flow, set the callback to `YOUR_CLOUD_RUN_URL/auth/callback` instead. + - After OAuth completes, the app will redirect users to `/auth/callback` for the success page. 4. **Get Credentials:** - Click "Register application" @@ -354,14 +356,14 @@ If you plan to run GitHub Actions from branches other than `main`, also add the 3. **Enable redirect on update (important for multiple Discord servers):** - Turn on **Redirect on update** so GitHub redirects back to the Setup URL even when the App is already installed. - This lets a second Discord server complete setup using the same org installation. -3. **Permissions (read-only):** +4. **Permissions (read-only):** - Metadata (required), Contents, Issues, Pull requests - Webhooks: OFF -4. **Install target:** choose **Any account** so anyone can install it. -5. **Generate a private key:** +5. **Install target:** choose **Any account** so anyone can install it. +6. **Generate a private key:** - Download the `.pem` file - Base64 it (keep BEGIN/END lines): `base64 -w 0 path/to/private-key.pem` -6. **Set `.env` values:** +7. **Set `.env` values:** - `GITHUB_APP_ID=...` (App ID from the GitHub App page) - `GITHUB_APP_PRIVATE_KEY_B64=...` (base64 from step 5) - `GITHUB_APP_SLUG=...` (the app slug shown in the app page URL) From 5f022ea691c308401067663356bab6117e8f0cb0 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 29 Dec 2025 12:44:16 +0700 Subject: [PATCH 25/30] feat: bulk sync on setup and per-user stats lookup --- .github/workflows/discord_bot_pipeline.yml | 50 ++++++++++------- discord_bot/src/bot/auth.py | 48 ++++++++++++++++- discord_bot/src/bot/commands/user_commands.py | 53 +++++++++++++++++-- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 9da3440..8a49f3f 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -57,6 +57,7 @@ jobs: env: GITHUB_APP_ID: ${{ secrets.GH_APP_ID }} GITHUB_APP_PRIVATE_KEY_B64: ${{ secrets.GH_APP_PRIVATE_KEY_B64 }} + TARGET_ORG: ${{ github.event.inputs.organization }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | @@ -98,6 +99,7 @@ jobs: print('Getting registered organizations...') mt_client = get_mt_client() + target_org = os.getenv('TARGET_ORG') or None # Get all registered Discord servers import firebase_admin @@ -108,8 +110,8 @@ jobs: print(f'Found {len(servers)} total servers in Firestore:') for server_id, server_data in servers.items(): - print(f' Server ID: {server_id}') - print(f' Data: {server_data}') + print(f' Server ID: {server_id}') + print(f' Data: {server_data}') # Extract unique GitHub installations (preferred) with a stable org key installations = {} @@ -124,6 +126,15 @@ jobs: print(f'Available keys: {list(server_config.keys())}') print(f'Found {len(installations)} unique installations: {installations}') + + if target_org: + target_org_lower = target_org.lower() + installations = { + installation_id: github_org + for installation_id, github_org in installations.items() + if str(github_org).lower() == target_org_lower + } + print(f'Filtered installations for target org {target_org}: {installations}') # Collect data for each installation (GitHub App token) all_org_data = {} @@ -206,6 +217,7 @@ jobs: - name: Store Data in Multi-Tenant Firestore env: GOOGLE_APPLICATION_CREDENTIALS: discord_bot/config/credentials.json + TARGET_ORG: ${{ github.event.inputs.organization }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }} run: | @@ -213,15 +225,19 @@ jobs: python -u -c " from shared.firestore import get_mt_client import json + import os print('Loading all processed data...') with open('all_processed_data.json', 'r') as f: all_processed_data = json.load(f) mt_client = get_mt_client() + target_org = os.getenv('TARGET_ORG') or None # Store data for each organization for github_org, data in all_processed_data.items(): + if target_org and str(github_org).lower() != target_org.lower(): + continue print(f'Storing data for organization: {github_org}') contributions = data['contributions'] @@ -252,22 +268,20 @@ jobs: print(f'Stored labels for {labels_stored} repositories in {github_org}') - # Update org-scoped user contribution data (per Discord server/org) - user_mappings = {doc.id: doc.to_dict() for doc in mt_client.db.collection('discord_users').stream()} - stored_count = 0 - - for discord_id, user_mapping in user_mappings.items(): - github_id = user_mapping.get('github_id') - if not github_id: - continue - user_data = contributions.get(github_id) - if not user_data: - continue - org_user_data = {**user_mapping, **user_data} - if mt_client.set_org_document(github_org, 'discord_users', discord_id, org_user_data): - stored_count += 1 - - print(f'Updated org-scoped contribution data for {stored_count} users in {github_org}') + # Store per-username contributions for instant stats lookup + contribution_count = 0 + for username, user_data in contributions.items(): + payload = { + 'github_username': username, + 'pr_count': user_data.get('pr_count', 0), + 'issues_count': user_data.get('issues_count', 0), + 'commits_count': user_data.get('commits_count', 0), + 'stats': user_data.get('stats', {}), + 'rankings': user_data.get('rankings', {}) + } + if mt_client.set_org_document(github_org, 'contributions', username, payload): + contribution_count += 1 + print(f'Stored contribution data for {contribution_count} GitHub users in {github_org}') print('All organization data stored successfully!') " diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 8dac737..1fe21cd 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,6 +1,7 @@ import os import threading import time +import requests from flask import Flask, redirect, url_for, jsonify, session from flask_dance.contrib.github import make_github_blueprint, github from dotenv import load_dotenv @@ -363,6 +364,45 @@ def github_app_setup(): if not success: return "Error: Failed to save configuration", 500 + def trigger_initial_sync(org_name: str) -> bool: + """Trigger the GitHub Actions pipeline once after setup.""" + token = os.getenv("GITHUB_TOKEN") + repo_owner = os.getenv("REPO_OWNER") + repo_name = os.getenv("REPO_NAME", "disgitbot") + ref = os.getenv("WORKFLOW_REF", "main") + + if not token or not repo_owner: + print("Skipping pipeline trigger: missing GITHUB_TOKEN or REPO_OWNER") + return False + + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/discord_bot_pipeline.yml/dispatches" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + } + payload = { + "ref": ref, + "inputs": { + "organization": org_name + } + } + + try: + resp = requests.post(url, headers=headers, json=payload, timeout=20) + if resp.status_code in (201, 204): + existing_config = mt_client.get_server_config(guild_id) or {} + mt_client.set_server_config(guild_id, { + **existing_config, + "initial_sync_triggered_at": datetime.now().isoformat() + }) + return True + print(f"Failed to trigger pipeline: {resp.status_code} {resp.text[:200]}") + except Exception as exc: + print(f"Error triggering pipeline: {exc}") + return False + + sync_triggered = trigger_initial_sync(github_org) + success_page = """ @@ -403,6 +443,11 @@ def github_app_setup():

/link

2) Configure custom roles:

/configure roles
+ {% if sync_triggered %} +

✅ Initial sync started. Stats will appear shortly.

+ {% else %} +

⏳ Initial sync will run on the next scheduled pipeline.

+ {% endif %}

3) Try these commands:

/getstats
/halloffame
@@ -415,7 +460,8 @@ def github_app_setup(): success_page, guild_name=guild_name, github_org=github_org, - is_personal_install=is_personal_install + is_personal_install=is_personal_install, + sync_triggered=sync_triggered ) @app.route("/setup") diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index fd93763..829f059 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -8,6 +8,7 @@ from discord import app_commands import asyncio import threading +import datetime from ...services.role_service import RoleService from ..auth import get_github_username_for_user, wait_for_username from shared.firestore import get_document, set_document, get_mt_client @@ -118,7 +119,7 @@ async def link(interaction: discord.Interaction): await self._safe_followup( interaction, f"Successfully linked to GitHub user: `{github_username}`\n" - f"Stats and roles update on the next sync cycle." + f"Use `/getstats` to view your contribution data." ) else: await self._safe_followup(interaction, "Authentication timed out or failed. Please try again.") @@ -130,6 +131,47 @@ async def link(interaction: discord.Interaction): self.verification_lock.release() return link + + def _empty_user_stats(self) -> dict: + """Return an empty stats payload for users with no synced data yet.""" + current_month = datetime.datetime.utcnow().strftime("%B") + return { + "pr_count": 0, + "issues_count": 0, + "commits_count": 0, + "stats": { + "current_month": current_month, + "last_updated": "Not synced yet", + "pr": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + }, + "issue": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + }, + "commit": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + } + }, + "rankings": {} + } def _unlink_command(self): """Create the unlink command.""" @@ -189,8 +231,13 @@ 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 - # Fetch org-scoped stats for this server - user_data = get_document('discord_users', user_id, discord_server_id) or {} + github_org = 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.") + return + + # Fetch org-scoped stats for this GitHub username + user_data = mt_client.get_org_document(github_org, 'contributions', github_username) or self._empty_user_stats() # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) From 1e507b1e8c8a4f8e2b4b2487fd05e100dc223169 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 29 Dec 2025 14:54:37 +0700 Subject: [PATCH 26/30] fix: avoid duplicate initial sync triggers --- discord_bot/src/bot/auth.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 1fe21cd..685a5b7 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -317,7 +317,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 + from datetime import datetime, timedelta from src.services.github_app_service import GitHubAppService installation_id = request.args.get('installation_id') @@ -375,6 +375,17 @@ def trigger_initial_sync(org_name: str) -> bool: print("Skipping pipeline trigger: missing GITHUB_TOKEN or REPO_OWNER") return False + existing_config = mt_client.get_server_config(guild_id) or {} + last_trigger = existing_config.get("initial_sync_triggered_at") + if last_trigger: + try: + last_dt = datetime.fromisoformat(last_trigger) + if datetime.now() - last_dt < timedelta(minutes=10): + print("Skipping pipeline trigger: recent sync already triggered") + return False + except ValueError: + pass + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/discord_bot_pipeline.yml/dispatches" headers = { "Authorization": f"token {token}", @@ -390,7 +401,6 @@ def trigger_initial_sync(org_name: str) -> bool: try: resp = requests.post(url, headers=headers, json=payload, timeout=20) if resp.status_code in (201, 204): - existing_config = mt_client.get_server_config(guild_id) or {} mt_client.set_server_config(guild_id, { **existing_config, "initial_sync_triggered_at": datetime.now().isoformat() From 43c7994882b06b73cab734abd956df63cd69e5cc Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 29 Dec 2025 15:55:13 +0700 Subject: [PATCH 27/30] fix: preserve setup config to avoid duplicate sync triggers --- discord_bot/src/bot/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 685a5b7..e836b4f 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -351,7 +351,9 @@ def github_app_setup(): is_personal_install = github_account_type == 'User' 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, 'github_org': github_org, 'github_installation_id': int(installation_id), 'github_account': github_account, From 4ca70c86b2f6928d1ffa7a1590569c611cc1f9c5 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 29 Dec 2025 17:00:46 +0700 Subject: [PATCH 28/30] docs: finalize readme --- discord_bot/README.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/discord_bot/README.md b/discord_bot/README.md index 90c3496..6ed7753 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -124,16 +124,14 @@ cp discord_bot/config/.env.example discord_bot/config/.env **Your `.env` file needs these values:** - `DISCORD_BOT_TOKEN=` (Discord bot authentication) +- `GITHUB_TOKEN=` (Github API access) - `GITHUB_CLIENT_ID=` (GitHub OAuth app ID) - `GITHUB_CLIENT_SECRET=` (GitHub OAuth app secret) - `GITHUB_APP_ID=` (GitHub App ID) - `GITHUB_APP_PRIVATE_KEY_B64=` (GitHub App private key, base64) - `GITHUB_APP_SLUG=` (GitHub App slug) - `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) - -**Also required for PR review tooling or legacy single-org flow:** -- `GITHUB_TOKEN=` (PAT for PR review or legacy single-org pipeline) -- `REPO_OWNER=` (Org name for legacy single-org pipeline) +- `REPO_OWNER=` (Owner of the Disgitbot repo that hosts the workflow dispatch. Ex: ruxailab) **Additional files you need:** - `discord_bot/config/credentials.json` (Firebase/Google Cloud credentials) @@ -258,7 +256,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - `.env` file: `GITHUB_TOKEN=your_token_here` - GitHub Secret: `GH_TOKEN` -**What this does:** Allows PR review tooling and legacy single-org workflows to access the GitHub API. +**What this does:** Allows the bot to access dispatch the Github Actions Workflow 1. **Go to GitHub Token Settings:** https://github.com/settings/tokens 2. **Create New Token:** @@ -376,13 +374,13 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - `.env` file: `REPO_OWNER=your_org_name` - GitHub Secret: `REPO_OWNER` -**What this does:** Tells the bot which GitHub organization's repositories to monitor for contributions. +**What this does:** Tells the bot which Disgitbot repo owns the GitHub Actions workflow (used for workflow dispatch). The org you track comes from GitHub App installation during `/setup`. -1. **Find Your Organization Name:** - - Go to your organization's repositories page (example: `https://github.com/orgs/ruxailab/repositories`) - - The organization name is the part after `/orgs/` (example: `ruxailab`) +1. **Find the Disgitbot repo owner:** + - Example repo: `https://github.com/ruxailab/disgitbot` + - The owner is the first path segment (`ruxailab`) 2. **Set in Configuration:** - - **Add to `.env`:** `REPO_OWNER=your_org_name` (example: `REPO_OWNER=ruxailab`) + - **Add to `.env`:** `REPO_OWNER=your_repo_owner` (example: `REPO_OWNER=ruxailab`) - **Add to GitHub Secrets:** Create secret named `REPO_OWNER` with the same value - **Important:** Use ONLY the organization name, NOT the full URL @@ -431,7 +429,7 @@ The deployment script will: # Trigger the data pipeline to fetch data and assign roles gh workflow run discord_bot_pipeline.yml -f organization= ``` - Use the same organization name you configured in `REPO_OWNER` when invoking the workflow (for example `-f organization=ruxailab`). This runs the full data pipeline, pushes metrics to Firestore, and refreshes Discord roles/channels for every registered server. + Use the GitHub org you want to sync (the org where the GitHub App is installed), for example `-f organization=your-org`. This runs the full data pipeline, pushes metrics to Firestore, and refreshes Discord roles/channels for every registered server connected to that org. --- From 3f26d7e3fbe8f41ebdc592840a7e3e31735e8fb1 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 31 Dec 2025 00:22:46 +0700 Subject: [PATCH 29/30] refactor: remove unused oauth_flow state and unsude github_user_data storage --- discord_bot/src/bot/auth.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index e836b4f..bd0ec76 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -66,7 +66,7 @@ def debug_servers(): mt_client = get_mt_client() # Get all servers - servers_ref = mt_client.db.collection('servers') + servers_ref = mt_client.db.collection('discord_servers') servers = [] for doc in servers_ref.stream(): @@ -208,7 +208,6 @@ def start_oauth(discord_user_id): # Store user ID in session for callback session['discord_user_id'] = discord_user_id - session['oauth_flow'] = 'link' print(f"Starting OAuth for Discord user: {discord_user_id}") @@ -262,11 +261,9 @@ def github_callback(): with oauth_sessions_lock: oauth_sessions[discord_user_id] = { 'status': 'completed', - 'github_username': github_username, - 'github_user_data': github_user + 'github_username': github_username } - session.pop('oauth_flow', None) session.pop('discord_user_id', None) print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") From e5589724b08771bf5f88d37dfd9c166c539452fa Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 31 Dec 2025 00:30:13 +0700 Subject: [PATCH 30/30] refactor: remove firestore fallbacks --- discord_bot/src/bot/commands/user_commands.py | 1 - .../pipeline/processors/reviewer_processor.py | 14 +- shared/firestore.py | 258 ++++++------------ 3 files changed, 94 insertions(+), 179 deletions(-) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 829f059..bdb16b5 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -190,7 +190,6 @@ async def unlink(interaction: discord.Interaction): return mt_client.set_user_mapping(discord_user_id, {}) - set_document('discord_users', discord_user_id, {}, discord_server_id=discord_server_id) await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") print(f"Unlinked Discord user {interaction.user.name}") diff --git a/discord_bot/src/pipeline/processors/reviewer_processor.py b/discord_bot/src/pipeline/processors/reviewer_processor.py index a111af5..fa02b4f 100644 --- a/discord_bot/src/pipeline/processors/reviewer_processor.py +++ b/discord_bot/src/pipeline/processors/reviewer_processor.py @@ -7,7 +7,7 @@ import time from typing import Dict, Any, List, Optional -from shared.firestore import get_mt_client, get_document +from shared.firestore import get_mt_client def generate_reviewer_pool( @@ -21,12 +21,12 @@ def generate_reviewer_pool( if not all_contributions: return {} - if github_org: - existing_config = ( - get_mt_client().get_org_document(github_org, 'pr_config', 'reviewers') or {} - ) - else: - existing_config = get_document('pr_config', 'reviewers') or {} + if not github_org: + raise ValueError("github_org is required to load reviewer config") + + existing_config = ( + get_mt_client().get_org_document(github_org, 'pr_config', 'reviewers') or {} + ) manual_reviewers = existing_config.get('manual_reviewers', []) # Get contributors sorted by PR count (all-time) diff --git a/shared/firestore.py b/shared/firestore.py index d94456f..f986beb 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -147,62 +147,6 @@ def _get_firestore_client(): _db = firestore.client() return _db -def get_document(collection: str, document_id: str) -> Optional[Dict[str, Any]]: - """Get a document from Firestore.""" - try: - db = _get_firestore_client() - doc = db.collection(collection).document(document_id).get() - return doc.to_dict() if doc.exists else None - except Exception as e: - print(f"Error getting document {collection}/{document_id}: {e}") - return None - -def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: bool = False) -> bool: - """Set a document in Firestore.""" - try: - db = _get_firestore_client() - db.collection(collection).document(document_id).set(data, merge=merge) - return True - except Exception as e: - print(f"Error setting document {collection}/{document_id}: {e}") - return False - -def update_document(collection: str, document_id: str, data: Dict[str, Any]) -> bool: - """Update a document in Firestore.""" - try: - db = _get_firestore_client() - db.collection(collection).document(document_id).update(data) - return True - except Exception as e: - print(f"Error updating document {collection}/{document_id}: {e}") - return False - -def delete_document(collection: str, document_id: str) -> bool: - """Delete a document from Firestore.""" - try: - db = _get_firestore_client() - db.collection(collection).document(document_id).delete() - return True - except Exception as e: - print(f"Error deleting document {collection}/{document_id}: {e}") - return False - -def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Query a collection with optional filters.""" - try: - db = _get_firestore_client() - query = db.collection(collection) - - if filters: - for field, value in filters.items(): - query = query.where(field, '==', value) - - docs = query.stream() - return {doc.id: doc.to_dict() for doc in docs} - except Exception as e: - print(f"Error querying collection {collection}: {e}") - return {} - # Global multi-tenant instance _mt_client = None @@ -213,168 +157,140 @@ def get_mt_client() -> FirestoreMultiTenant: _mt_client = FirestoreMultiTenant() return _mt_client -# Legacy compatibility functions - these now require discord_server_id context +ORG_SCOPED_COLLECTIONS = { + 'repo_stats', + 'pr_config', + 'repository_labels', + 'contributions', +} +GLOBAL_COLLECTIONS = { + 'global_config', + 'notification_config', +} + def get_document(collection: str, document_id: str, discord_server_id: str = None) -> Optional[Dict[str, Any]]: - """Get a document from Firestore. For org-scoped collections, requires discord_server_id.""" + """Get a document from Firestore with explicit collection routing.""" mt_client = get_mt_client() - - # Handle organization-scoped collections - if collection in ['repo_stats', 'pr_config', 'repository_labels']: + + if collection in ORG_SCOPED_COLLECTIONS: if not discord_server_id: raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") github_org = mt_client.get_org_from_server(discord_server_id) if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return None + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") return mt_client.get_org_document(github_org, collection, document_id) - # Handle org-scoped user stats - if collection == 'discord_users' and discord_server_id: - github_org = mt_client.get_org_from_server(discord_server_id) - if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return None - return mt_client.get_org_document(github_org, collection, document_id) - - # Handle user mappings (old 'discord' collection) - if collection == 'discord': + if collection == 'discord_users': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") return mt_client.get_user_mapping(document_id) - - # Handle server configs - if collection == 'servers': - return mt_client.get_server_config(document_id) - - # Fallback to old behavior - try: + + if collection in GLOBAL_COLLECTIONS: db = _get_firestore_client() doc = db.collection(collection).document(document_id).get() return doc.to_dict() if doc.exists else None - except Exception as e: - print(f"Error getting document {collection}/{document_id}: {e}") - return None + + raise ValueError(f"Unsupported collection: {collection}") def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: bool = False, discord_server_id: str = None) -> bool: - """Set a document in Firestore. For org-scoped collections, requires discord_server_id.""" + """Set a document in Firestore with explicit collection routing.""" mt_client = get_mt_client() - - # Handle organization-scoped collections - if collection in ['repo_stats', 'pr_config', 'repository_labels']: + + if collection in ORG_SCOPED_COLLECTIONS: if not discord_server_id: raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") github_org = mt_client.get_org_from_server(discord_server_id) if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return False + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") return mt_client.set_org_document(github_org, collection, document_id, data, merge) - # Handle org-scoped user stats - if collection == 'discord_users' and discord_server_id: - github_org = mt_client.get_org_from_server(discord_server_id) - if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return False - return mt_client.set_org_document(github_org, collection, document_id, data, merge) - - # Handle user mappings (old 'discord' collection) - if collection == 'discord': + if collection == 'discord_users': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") return mt_client.set_user_mapping(document_id, data) - - # Handle server configs - if collection == 'servers': - return mt_client.set_server_config(document_id, data) - - # Fallback to old behavior - try: + + if collection in GLOBAL_COLLECTIONS: db = _get_firestore_client() db.collection(collection).document(document_id).set(data, merge=merge) return True - except Exception as e: - print(f"Error setting document {collection}/{document_id}: {e}") - return False + + raise ValueError(f"Unsupported collection: {collection}") def update_document(collection: str, document_id: str, data: Dict[str, Any], discord_server_id: str = None) -> bool: - """Update a document in Firestore. For org-scoped collections, requires discord_server_id.""" + """Update a document in Firestore with explicit collection routing.""" mt_client = get_mt_client() - - # Handle organization-scoped collections - if collection in ['repo_stats', 'pr_config', 'repository_labels']: + + if collection in ORG_SCOPED_COLLECTIONS: if not discord_server_id: raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") github_org = mt_client.get_org_from_server(discord_server_id) if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return False + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") return mt_client.update_org_document(github_org, collection, document_id, data) - # Handle org-scoped user stats - if collection == 'discord_users' and discord_server_id: - github_org = mt_client.get_org_from_server(discord_server_id) - if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return False - return mt_client.update_org_document(github_org, collection, document_id, data) - - # Handle user mappings (old 'discord' collection) - if collection == 'discord': - # For users, update is the same as set + if collection == 'discord_users': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") return mt_client.set_user_mapping(document_id, data) - - # Fallback to old behavior - try: + + if collection in GLOBAL_COLLECTIONS: db = _get_firestore_client() db.collection(collection).document(document_id).update(data) return True - except Exception as e: - print(f"Error updating document {collection}/{document_id}: {e}") - return False -def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, discord_server_id: str = None) -> Dict[str, Any]: - """Query a collection with optional filters. For org-scoped collections, requires discord_server_id.""" + raise ValueError(f"Unsupported collection: {collection}") + +def delete_document(collection: str, document_id: str, discord_server_id: str = None) -> bool: + """Delete a document in Firestore with explicit collection routing.""" mt_client = get_mt_client() - - # Handle organization-scoped collections - if collection in ['repo_stats', 'pr_config', 'repository_labels']: + + if collection in ORG_SCOPED_COLLECTIONS: if not discord_server_id: raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") github_org = mt_client.get_org_from_server(discord_server_id) if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return {} - return mt_client.query_org_collection(github_org, collection, filters) + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + mt_client.db.collection('organizations').document(github_org).collection(collection).document(document_id).delete() + return True + + if collection == 'discord_users': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") + _get_firestore_client().collection('discord_users').document(document_id).delete() + return True + + if collection in GLOBAL_COLLECTIONS: + _get_firestore_client().collection(collection).document(document_id).delete() + return True + + raise ValueError(f"Unsupported collection: {collection}") - # Handle org-scoped user stats - if collection == 'discord_users' and discord_server_id: +def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, discord_server_id: str = None) -> Dict[str, Any]: + """Query a collection with explicit collection routing.""" + mt_client = get_mt_client() + + if collection in ORG_SCOPED_COLLECTIONS: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") github_org = mt_client.get_org_from_server(discord_server_id) if not github_org: - print(f"No GitHub org found for Discord server: {discord_server_id}") - return {} + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") return mt_client.query_org_collection(github_org, collection, filters) - - # Handle user mappings (old 'discord' collection) - return all users - if collection == 'discord': - try: - db = _get_firestore_client() - query = db.collection('users') - if filters: - for field, value in filters.items(): - query = query.where(field, '==', value) - docs = query.stream() - return {doc.id: doc.to_dict() for doc in docs} - except Exception as e: - print(f"Error querying users collection: {e}") - return {} - - # Fallback to old behavior - try: + + if collection == 'discord_users': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") + db = _get_firestore_client() + query = db.collection('discord_users') + elif collection in GLOBAL_COLLECTIONS: db = _get_firestore_client() query = db.collection(collection) - - if filters: - for field, value in filters.items(): - query = query.where(field, '==', value) - - docs = query.stream() - return {doc.id: doc.to_dict() for doc in docs} - except Exception as e: - print(f"Error querying collection {collection}: {e}") - return {} + else: + raise ValueError(f"Unsupported collection: {collection}") + + if filters: + for field, value in filters.items(): + query = query.where(field, '==', value) + + docs = query.stream() + return {doc.id: doc.to_dict() for doc in docs}