From 4382a2ac88e80f032cc1cb6cc88a489320683b16 Mon Sep 17 00:00:00 2001 From: Tq Date: Sun, 14 Sep 2025 14:30:04 -0400 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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} From 0f56f5b4b5c03fbaceb6623650276540af7f1ad4 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 31 Dec 2025 20:45:42 +0700 Subject: [PATCH 31/64] fix: align webhook status storage --- discord_bot/src/bot/commands/notification_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 5a80d5d..eb77512 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -194,7 +194,7 @@ async def webhook_status(interaction: discord.Interaction): try: from shared.firestore import get_document - webhook_config = get_document('notification_config', 'webhooks') + webhook_config = get_document('global_config', 'ci_cd_webhooks') embed = discord.Embed( title="Webhook Configuration Status", @@ -252,4 +252,4 @@ def _is_valid_webhook_url(self, url: str) -> bool: def _is_valid_repo_format(self, repo: str) -> bool: """Validate repository format (owner/repo).""" repo_pattern = r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$' - return bool(re.match(repo_pattern, repo)) \ No newline at end of file + return bool(re.match(repo_pattern, repo)) From 9441371ef44addb346a7927ad75e50086c5eb672 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Wed, 31 Dec 2025 21:21:21 +0700 Subject: [PATCH 32/64] fix: show org last sync for zero-contribution users --- discord_bot/src/bot/commands/user_commands.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index f05eba3..3a40fe8 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -132,7 +132,7 @@ async def link(interaction: discord.Interaction): return link - def _empty_user_stats(self) -> dict: + def _empty_user_stats(self, last_updated: str | None = None) -> dict: """Return an empty stats payload for users with no synced data yet.""" current_month = datetime.datetime.utcnow().strftime("%B") return { @@ -141,7 +141,7 @@ def _empty_user_stats(self) -> dict: "commits_count": 0, "stats": { "current_month": current_month, - "last_updated": "Not synced yet", + "last_updated": last_updated or "Not synced yet", "pr": { "daily": 0, "weekly": 0, @@ -236,7 +236,11 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): 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() + user_data = mt_client.get_org_document(github_org, 'contributions', github_username) + if not user_data: + metrics = get_document('repo_stats', 'metrics', discord_server_id) + last_updated = metrics.get('last_updated') if metrics else None + user_data = self._empty_user_stats(last_updated) # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) From 3897c013c0eee23d27d2e0c98481469e0b73ad2a Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 14:42:16 +0700 Subject: [PATCH 33/64] feat(shared): migrate firestore storage to organization-scoped paths --- pr_review/utils/reviewer_assigner.py | 13 +++++++------ shared/firestore.py | 23 ++++++++++++----------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pr_review/utils/reviewer_assigner.py b/pr_review/utils/reviewer_assigner.py index f08131f..1b76f42 100644 --- a/pr_review/utils/reviewer_assigner.py +++ b/pr_review/utils/reviewer_assigner.py @@ -14,22 +14,23 @@ class ReviewerAssigner: """Automatically assigns reviewers to pull requests using random selection.""" - def __init__(self, config_path: Optional[str] = None): + def __init__(self, github_org: Optional[str] = None): """Initialize the reviewer assigner with Firestore configuration.""" + self.github_org = github_org self.reviewers = self._load_reviewers() def _load_reviewers(self) -> List[str]: """Load reviewer pool from Firestore configuration.""" try: - logger.info("REVIEWER DEBUG: Attempting to load reviewers from global_config/reviewer_pool") - reviewer_data = get_document('global_config', 'reviewer_pool') + logger.info(f"REVIEWER DEBUG: Attempting to load reviewers for org: {self.github_org}") + reviewer_data = get_document('pr_config', 'reviewers', github_org=self.github_org) if reviewer_data and 'reviewers' in reviewer_data: reviewers = reviewer_data['reviewers'] - logger.info(f"REVIEWER DEBUG: Successfully loaded {len(reviewers)} reviewers: {reviewers}") + logger.info(f"REVIEWER DEBUG: Successfully loaded {len(reviewers)} reviewers") return reviewers - logger.error("REVIEWER DEBUG: No reviewer configuration found in Firestore") + logger.error(f"REVIEWER DEBUG: No reviewer configuration found for org {self.github_org} in pr_config/reviewers") logger.error(f"REVIEWER DEBUG: Retrieved data: {reviewer_data}") return [] @@ -104,7 +105,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('global_config', 'reviewer_pool', reviewer_data) + success = set_document('pr_config', 'reviewers', reviewer_data, github_org=self.github_org) if success: logger.info(f"Saved {len(self.reviewers)} reviewers to Firestore") else: diff --git a/shared/firestore.py b/shared/firestore.py index f986beb..161ba3b 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -165,19 +165,19 @@ def get_mt_client() -> FirestoreMultiTenant: } GLOBAL_COLLECTIONS = { 'global_config', - 'notification_config', } -def get_document(collection: str, document_id: str, discord_server_id: str = None) -> Optional[Dict[str, Any]]: +def get_document(collection: str, document_id: str, discord_server_id: str = None, github_org: str = None) -> Optional[Dict[str, Any]]: """Get a document from Firestore 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: - raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + if not discord_server_id: + raise ValueError(f"discord_server_id or github_org required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") return mt_client.get_org_document(github_org, collection, document_id) if collection == 'discord_users': @@ -192,16 +192,17 @@ def get_document(collection: str, document_id: str, discord_server_id: str = Non 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: +def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: bool = False, discord_server_id: str = None, github_org: str = None) -> bool: """Set a document in Firestore 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: - raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + if not discord_server_id: + raise ValueError(f"discord_server_id or github_org required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + 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) if collection == 'discord_users': From 3616127084dbf648f853386dc1eb82454cb40706 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 14:43:16 +0700 Subject: [PATCH 34/64] feat(notifications): support multiple discord servers per github organization --- .../src/bot/commands/notification_commands.py | 41 ++++-- .../src/services/notification_service.py | 125 +++++++++++------- 2 files changed, 110 insertions(+), 56 deletions(-) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index eb77512..140ce17 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -48,7 +48,11 @@ async def set_webhook( return # Set the webhook URL - success = WebhookManager.set_webhook_url(notification_type, webhook_url) + success = WebhookManager.set_webhook_url( + notification_type, + webhook_url, + discord_server_id=str(interaction.guild_id) + ) if success: await interaction.followup.send( @@ -85,7 +89,10 @@ async def add_repo(interaction: discord.Interaction, repository: str): return # Add repository to monitoring list - success = WebhookManager.add_monitored_repository(repository) + success = WebhookManager.add_monitored_repository( + repository, + discord_server_id=str(interaction.guild_id) + ) if success: await interaction.followup.send( @@ -121,7 +128,10 @@ async def remove_repo(interaction: discord.Interaction, repository: str): return # Remove repository from monitoring list - success = WebhookManager.remove_monitored_repository(repository) + success = WebhookManager.remove_monitored_repository( + repository, + discord_server_id=str(interaction.guild_id) + ) if success: await interaction.followup.send( @@ -148,7 +158,9 @@ async def list_repos(interaction: discord.Interaction): await interaction.response.defer() try: - repositories = WebhookManager.get_monitored_repositories() + repositories = WebhookManager.get_monitored_repositories( + discord_server_id=str(interaction.guild_id) + ) embed = discord.Embed( title="CI/CD Monitoring Status", @@ -194,15 +206,24 @@ async def webhook_status(interaction: discord.Interaction): try: from shared.firestore import get_document - webhook_config = get_document('global_config', 'ci_cd_webhooks') + webhook_config = get_document( + 'pr_config', + 'webhooks', + discord_server_id=str(interaction.guild_id) + ) embed = discord.Embed( title="Webhook Configuration Status", color=discord.Color.blue() ) - # Check PR automation webhook - pr_webhook = webhook_config.get('pr_automation_webhook_url') if webhook_config else None + # New logic: Look in the webhooks list for this specific server + webhooks_list = webhook_config.get('webhooks', []) if webhook_config else [] + + # Find PR automation webhook for THIS server + pr_webhook_entry = next((w for w in webhooks_list if w.get('type') == 'pr_automation' and w.get('server_id') == str(interaction.guild_id)), None) + pr_webhook = pr_webhook_entry['url'] if pr_webhook_entry else webhook_config.get('pr_automation_webhook_url') + pr_status = "Configured" if pr_webhook else "Not configured" embed.add_field( name="PR Automation Notifications", @@ -210,8 +231,10 @@ async def webhook_status(interaction: discord.Interaction): inline=True ) - # Check CI/CD webhook - cicd_webhook = webhook_config.get('cicd_webhook_url') if webhook_config else None + # Find CI/CD webhook for THIS server + cicd_webhook_entry = next((w for w in webhooks_list if w.get('type') == 'cicd' and w.get('server_id') == str(interaction.guild_id)), None) + cicd_webhook = cicd_webhook_entry['url'] if cicd_webhook_entry else webhook_config.get('cicd_webhook_url') + cicd_status = "Configured" if cicd_webhook else "Not configured" embed.add_field( name="CI/CD Notifications", diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index b27da33..ef4d766 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -33,19 +33,13 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self.session.close() async def send_pr_automation_notification(self, pr_data: Dict[str, Any], comment_body: str) -> bool: - """ - Send PR automation notification to Discord channel. - - Args: - pr_data: PR processing results from automation system - comment_body: The comment body that was posted to GitHub - - Returns: - Success status - """ + """Send PR automation notification.""" try: - webhook_url = await self._get_webhook_url('pr_automation') - if not webhook_url: + repo = pr_data.get('repository', '') + github_org = repo.split('/')[0] if '/' in repo else None + + webhook_urls = await self._get_webhook_urls('pr_automation', github_org=github_org) + if not webhook_urls: logger.warning("No webhook URL configured for PR automation notifications") return False @@ -56,7 +50,11 @@ async def send_pr_automation_notification(self, pr_data: Dict[str, Any], comment "avatar_url": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" } - return await self._send_webhook(webhook_url, payload) + success = False + for url in webhook_urls: + if await self._send_webhook(url, payload): + success = True + return success except Exception as e: logger.error(f"Failed to send PR automation notification: {e}") @@ -64,23 +62,11 @@ async def send_pr_automation_notification(self, pr_data: Dict[str, Any], comment async def send_cicd_notification(self, repo: str, workflow_name: str, status: str, run_url: str, commit_sha: str, branch: str) -> bool: - """ - Send CI/CD status notification to Discord channel. - - Args: - repo: Repository name (owner/repo) - workflow_name: GitHub Actions workflow name - status: Workflow status (success, failure, in_progress, cancelled) - run_url: URL to the workflow run - commit_sha: Commit SHA that triggered the workflow - branch: Branch name - - Returns: - Success status - """ + """Send CI/CD status notification.""" try: - webhook_url = await self._get_webhook_url('cicd') - if not webhook_url: + github_org = repo.split('/')[0] if '/' in repo else None + webhook_urls = await self._get_webhook_urls('cicd', github_org=github_org) + if not webhook_urls: logger.warning("No webhook URL configured for CI/CD notifications") return False @@ -91,7 +77,11 @@ async def send_cicd_notification(self, repo: str, workflow_name: str, status: st "avatar_url": "https://github.githubassets.com/images/modules/logos_page/Octocat.png" } - return await self._send_webhook(webhook_url, payload) + success = False + for url in webhook_urls: + if await self._send_webhook(url, payload): + success = True + return success except Exception as e: logger.error(f"Failed to send CI/CD notification: {e}") @@ -214,14 +204,35 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, return embed - async def _get_webhook_url(self, notification_type: str) -> Optional[str]: - """Get webhook URL for specified notification type.""" + async def _get_webhook_urls(self, notification_type: str, github_org: str = None) -> List[str]: + """Get all webhook URLs for specified notification type.""" + urls = [] try: - webhook_config = get_document('global_config', 'ci_cd_webhooks') - if not webhook_config: - return None + # First try org-scoped config + if github_org: + webhook_config = get_document('pr_config', 'webhooks', github_org=github_org) + if webhook_config: + # New list format support + if 'webhooks' in webhook_config: + urls.extend([ + w['url'] for w in webhook_config['webhooks'] + if w.get('type') == notification_type and w.get('url') + ]) + + # Legacy fallback (single string format) + legacy_url = webhook_config.get(f'{notification_type}_webhook_url') + if legacy_url and legacy_url not in urls: + urls.append(legacy_url) + + # Fallback to global config (legacy support) + if not urls: + webhook_config = get_document('global_config', 'ci_cd_webhooks') + if webhook_config: + legacy_url = webhook_config.get(f'{notification_type}_webhook_url') + if legacy_url: + urls.append(legacy_url) - return webhook_config.get(f'{notification_type}_webhook_url') + return urls except Exception as e: logger.error(f"Failed to get webhook URL for {notification_type}: {e}") return None @@ -252,23 +263,43 @@ class WebhookManager: """Manages webhook URL configuration and repository monitoring.""" @staticmethod - def set_webhook_url(notification_type: str, webhook_url: str) -> bool: + def set_webhook_url(notification_type: str, webhook_url: str, discord_server_id: str = None) -> bool: """Set webhook URL for specified notification type.""" try: - webhook_config = get_document('global_config', 'ci_cd_webhooks') or {} + webhook_config = get_document('pr_config', 'webhooks', discord_server_id=discord_server_id) or {} + + # Initialize modern list format + if 'webhooks' not in webhook_config: + webhook_config['webhooks'] = [] + + # Remove any existing webhook for THIS server and THIS type to avoid duplicates + webhook_config['webhooks'] = [ + w for w in webhook_config['webhooks'] + if not (w.get('server_id') == discord_server_id and w.get('type') == notification_type) + ] + + # Add new webhook entry + webhook_config['webhooks'].append({ + 'type': notification_type, + 'url': webhook_url, + 'server_id': discord_server_id, + 'last_updated': datetime.utcnow().isoformat() + }) + + # Maintain legacy field for backward compatibility webhook_config[f'{notification_type}_webhook_url'] = webhook_url webhook_config['last_updated'] = datetime.utcnow().isoformat() - return set_document('global_config', 'ci_cd_webhooks', webhook_config) + return set_document('pr_config', 'webhooks', webhook_config, discord_server_id=discord_server_id) except Exception as e: logger.error(f"Failed to set webhook URL: {e}") return False @staticmethod - def get_monitored_repositories() -> List[str]: + def get_monitored_repositories(discord_server_id: str = None) -> List[str]: """Get list of repositories being monitored for CI/CD notifications.""" try: - config = get_document('global_config', 'monitored_repositories') + config = get_document('pr_config', 'monitoring', discord_server_id=discord_server_id) if not config: return [] return config.get('repositories', []) @@ -277,10 +308,10 @@ def get_monitored_repositories() -> List[str]: return [] @staticmethod - def add_monitored_repository(repo: str) -> bool: + def add_monitored_repository(repo: str, discord_server_id: str = None) -> bool: """Add repository to CI/CD monitoring list.""" try: - config = get_document('global_config', 'monitored_repositories') or {'repositories': []} + config = get_document('pr_config', 'monitoring', discord_server_id=discord_server_id) or {'repositories': []} repos = config.get('repositories', []) if repo not in repos: @@ -288,17 +319,17 @@ def add_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('global_config', 'monitored_repositories', config) + return set_document('pr_config', 'monitoring', config, discord_server_id=discord_server_id) return True # Already exists except Exception as e: logger.error(f"Failed to add monitored repository: {e}") return False @staticmethod - def remove_monitored_repository(repo: str) -> bool: + def remove_monitored_repository(repo: str, discord_server_id: str = None) -> bool: """Remove repository from CI/CD monitoring list.""" try: - config = get_document('global_config', 'monitored_repositories') + config = get_document('pr_config', 'monitoring', discord_server_id=discord_server_id) if not config: return False @@ -308,7 +339,7 @@ def remove_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('global_config', 'monitored_repositories', config) + return set_document('pr_config', 'monitoring', config, discord_server_id=discord_server_id) return True # Already removed except Exception as e: logger.error(f"Failed to remove monitored repository: {e}") From ed20a40b5951fa63165e731e170d9f854d0e1710 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 14:43:48 +0700 Subject: [PATCH 35/64] fix(pr-review): resolve asyncio event loop errors and add aiohttp dependency --- pr_review/main.py | 74 ++++++++++++++++++++++++++------------ pr_review/requirements.txt | 3 +- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/pr_review/main.py b/pr_review/main.py index 9ee4152..850bc30 100644 --- a/pr_review/main.py +++ b/pr_review/main.py @@ -9,6 +9,12 @@ from typing import Dict, Any, List import json import asyncio +from pathlib import Path + +# Add project root to sys.path to allow importing from 'shared' +root_dir = Path(__file__).parent.parent +if str(root_dir) not in sys.path: + sys.path.append(str(root_dir)) from config import GITHUB_TOKEN, GOOGLE_API_KEY, REPO_OWNER from utils.github_client import GitHubClient @@ -32,10 +38,10 @@ def __init__(self): """Initialize the PR review system""" try: # Initialize components - self.github_client = GitHubClient() - self.metrics_calculator = MetricsCalculator() - self.ai_labeler = AIPRLabeler() - self.reviewer_assigner = ReviewerAssigner() + self.github = GitHubClient() + self.metrics = MetricsCalculator() + self.labeler = AIPRLabeler() + self.assigner = None # Will be initialized per request logger.info("PR Review System initialized successfully") @@ -44,7 +50,7 @@ def __init__(self): logger.error(f"Failed to initialize PR Review System: {e}") raise - def process_pull_request(self, repo: str, pr_number: int, experience_level: str = "intermediate") -> Dict[str, Any]: + async def process_pull_request(self, repo: str, pr_number: int, experience_level: str = "intermediate") -> Dict[str, Any]: """ Process a pull request with full automation pipeline @@ -60,13 +66,13 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str logger.info(f"Processing PR #{pr_number} in {repo}") # Step 1: Get PR details and diff - pr_details = self.github_client.get_pull_request_details(repo, pr_number) - pr_diff = self.github_client.get_pull_request_diff(repo, pr_number) - pr_files = self.github_client.get_pull_request_files(repo, pr_number) + pr_details = self.github.get_pull_request_details(repo, pr_number) + pr_diff = self.github.get_pull_request_diff(repo, pr_number) + pr_files = self.github.get_pull_request_files(repo, pr_number) # Step 2: Calculate metrics logger.info("Calculating PR metrics...") - metrics = self.metrics_calculator.calculate_pr_metrics(pr_diff, pr_files) + metrics = self.metrics.calculate_pr_metrics(pr_diff, pr_files) # Step 3: AI-based label prediction logger.info("Predicting labels with AI...") @@ -76,11 +82,13 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str 'diff': pr_diff, 'metrics': metrics } - predicted_labels = self.ai_labeler.predict_labels(pr_data, repo) + predicted_labels = self.labeler.predict_labels(pr_data, repo) # Step 4: Assign reviewers logger.info("Assigning reviewers...") - reviewer_assignments = self.reviewer_assigner.assign_reviewers(pr_data, repo) + repo_owner = repo.split('/')[0] if '/' in repo else repo + self.assigner = ReviewerAssigner(github_org=repo_owner) + reviewer_assignments = self.assigner.assign_reviewers(pr_data, repo) # Step 5: Skip AI review generation (not needed per mentor requirements) ai_review = {"summary": "AI review disabled - focusing on metrics and automation"} @@ -90,25 +98,22 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str label_names = [label['name'] for label in predicted_labels if label['confidence'] >= 0.5] if label_names: logger.info(f"Applying labels: {label_names}") - self.github_client.add_labels_to_pull_request(repo, pr_number, label_names) + self.github.add_labels_to_pull_request(repo, pr_number, label_names) # Step 7: Request reviewers if reviewer_assignments.get('reviewers'): reviewers = [r['username'] for r in reviewer_assignments['reviewers']] logger.info(f"Requesting reviewers: {reviewers}") - self.github_client.request_reviewers(repo, pr_number, reviewers) + self.github.request_reviewers(repo, pr_number, reviewers) # Step 8: Post comprehensive comment comment_body = self._build_comprehensive_comment( metrics, predicted_labels, reviewer_assignments, ai_review ) - self.github_client.create_issue_comment(repo, pr_number, comment_body) - - # Send Discord notification - asyncio.create_task(self._send_discord_notification(results, comment_body)) + self.github.create_issue_comment(repo, pr_number, comment_body) - # Return processing results + # Prepare results results = { 'pr_number': pr_number, 'repository': repo, @@ -119,19 +124,33 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str 'status': 'success' } + # Send Discord notification + try: + # In CLI/Action mode, we await to ensure it's sent before process exits + await self._send_discord_notification(results, comment_body) + except Exception as e: + logger.error(f"Failed to send Discord notification: {e}") + logger.info(f"Successfully processed PR #{pr_number}") return results - + except Exception as e: logger.error(f"Failed to process PR #{pr_number}: {e}") + import traceback + traceback.print_exc() + + # Send notification for failure error_results = { 'pr_number': pr_number, 'repository': repo, 'status': 'error', 'error': str(e) } - # Send error notification to Discord - asyncio.create_task(self._send_discord_notification(error_results, None)) + try: + await self._send_discord_notification(error_results, None) + except Exception: + pass + return error_results def _build_comprehensive_comment(self, metrics: Dict, labels: List[Dict], reviewers: Dict, ai_review: Dict) -> str: @@ -202,8 +221,17 @@ def main(): # Initialize system system = PRReviewSystem() - # Process the PR - results = system.process_pull_request(repo, pr_number, experience_level) + # Process the pull request + try: + results = asyncio.run(system.process_pull_request(repo, pr_number, experience_level)) + + # Exit with error code if processing failed + if results.get('status') == 'error': + sys.exit(1) + + except Exception as e: + logger.error(f"Fatal error: {e}") + sys.exit(1) # Print results in clean format print("\n" + "="*60) diff --git a/pr_review/requirements.txt b/pr_review/requirements.txt index 0677c53..9a5c0a8 100644 --- a/pr_review/requirements.txt +++ b/pr_review/requirements.txt @@ -5,4 +5,5 @@ google-generativeai>=0.3.0 pydantic>=2.0.0 typing-extensions>=4.8.0 radon>=6.0.1 -firebase-admin>=6.0.0 \ No newline at end of file +firebase-admin>=6.0.0 +aiohttp>=3.9.0 \ No newline at end of file From cc0bb72a268b6a0d81e16d2545d765b1c2d94e2f Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 14:44:33 +0700 Subject: [PATCH 36/64] security(auth): protect debug endpoint and add setup success notification --- discord_bot/src/bot/auth.py | 73 ++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 97e5a14..596082c 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -59,7 +59,10 @@ def index(): @app.route("/debug/servers") def debug_servers(): - """Debug endpoint to see registered servers""" + """Debug endpoint to see registered servers (Protected)""" + admin_token = os.getenv("ADMIN_TOKEN") + if not admin_token or request.args.get("token") != admin_token: + return jsonify({"error": "Unauthorized"}), 401 try: from shared.firestore import get_mt_client @@ -404,12 +407,80 @@ def trigger_initial_sync(org_name: str) -> bool: **existing_config, "initial_sync_triggered_at": datetime.now().isoformat() }) + if github_org: + try: + # Trigger Discord notification + import asyncio + from threading import Thread + + def run_async_notification(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(send_discord_setup_notification(guild_id, github_org)) + loop.close() + + Thread(target=run_async_notification).start() + + # Trigger initial data collection for this organization + trigger_data_pipeline_for_org(github_org) + except Exception as e: + print(f"Warning: Failed to trigger setup notifications: {e}") 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 + async def send_discord_setup_notification(guild_id: str, github_org: str): + """Send a success message to the Discord guild's system channel.""" + import discord + import os + + token = os.getenv('DISCORD_BOT_TOKEN') + if not token: + return + + intents = discord.Intents.default() + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + try: + guild = client.get_guild(int(guild_id)) + if guild: + channel = guild.system_channel + if not channel: + channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) + + if channel: + embed = discord.Embed( + title="✅ DisgitBot Setup Complete!", + description=f"This server is now connected to the GitHub organization: **{github_org}**", + color=0x43b581 + ) + embed.add_field(name="Next Steps", value="1. Use `/link` to connect your GitHub account\n2. Configure webhooks with `/set_webhook`", inline=False) + embed.set_footer(text="Powered by DisgitBot SaaS") + + await channel.send(embed=embed) + print(f"Sent setup success notification to guild {guild_id}") + + except Exception as e: + print(f"Error sending Discord setup notification: {e}") + finally: + await client.close() + + try: + await client.start(token) + except Exception as e: + print(f"Failed to start Discord client for notification: {e}") + + def trigger_data_pipeline_for_org(github_org): + # Placeholder for triggering a data pipeline for the given GitHub organization + # This would typically involve calling an external service or another part of the system + print(f"Triggering data pipeline for GitHub organization: {github_org}") + # Example: You might want to add a task to a queue here + pass + sync_triggered = trigger_initial_sync(github_org) success_page = """ From 30b92b1cd55a4bbcfec6bbc1e185a9b8401a77b9 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 15:19:10 +0700 Subject: [PATCH 37/64] fix(bot): improve setup reminders and update config templates --- discord_bot/config/.env.example | 1 + discord_bot/src/bot/bot.py | 46 +++++++++++++++++++++++++++++--- pr_review/utils/ai_pr_labeler.py | 3 ++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index ebf50d6..5f7e052 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -8,3 +8,4 @@ DISCORD_BOT_CLIENT_ID= GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY_B64= GITHUB_APP_SLUG= +ADMIN_TOKEN= diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index ac3a002..59d9b84 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -65,9 +65,21 @@ async def on_guild_join(guild): # 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)) + server_config = mt_client.get_server_config(str(guild.id)) or {} + + if not server_config.get('setup_completed'): + # Check if we sent a reminder very recently (24h cooldown) + last_reminder = server_config.get('setup_reminder_sent_at') + if last_reminder: + from datetime import datetime, timedelta + try: + last_dt = datetime.fromisoformat(last_reminder) + if datetime.now() - last_dt < timedelta(hours=24): + print(f"Skipping setup guidance for {guild.name}: already sent within 24h") + return + except ValueError: + pass - if not server_config: # Server not configured - send setup message to system channel system_channel = guild.system_channel if not system_channel: @@ -99,6 +111,13 @@ async def on_guild_join(guild): *This message will only appear once during setup.*""" await system_channel.send(setup_message) + + # Mark reminder as sent + from datetime import datetime + mt_client.set_server_config(str(guild.id), { + **server_config, + 'setup_reminder_sent_at': datetime.now().isoformat() + }) print(f"Sent setup guidance to server: {guild.name} (ID: {guild.id})") except Exception as e: @@ -116,9 +135,21 @@ 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)) + server_config = mt_client.get_server_config(str(guild.id)) or {} + + if not server_config.get('setup_completed'): + # Check if we sent a reminder very recently (24h cooldown) + last_reminder = server_config.get('setup_reminder_sent_at') + if last_reminder: + from datetime import datetime, timedelta + try: + last_dt = datetime.fromisoformat(last_reminder) + if datetime.now() - last_dt < timedelta(hours=24): + print(f"Skipping setup reminder for {guild.name}: already sent within 24h") + continue + except ValueError: + pass - if not server_config: # Server not configured system_channel = guild.system_channel if not system_channel: @@ -144,6 +175,13 @@ async def notify_unconfigured_servers(): *This is a one-time setup message.*""" await system_channel.send(setup_message) + + # Mark reminder as sent + from datetime import datetime + mt_client.set_server_config(str(guild.id), { + **server_config, + 'setup_reminder_sent_at': datetime.now().isoformat() + }) print(f"Sent setup reminder to server: {guild.name} (ID: {guild.id})") # Run the async function directly diff --git a/pr_review/utils/ai_pr_labeler.py b/pr_review/utils/ai_pr_labeler.py index 828dc32..fa7e6f3 100644 --- a/pr_review/utils/ai_pr_labeler.py +++ b/pr_review/utils/ai_pr_labeler.py @@ -53,7 +53,8 @@ def _get_repository_labels(self, repo: str) -> List[str]: from shared.firestore import get_document doc_id = repo.replace('/', '_') - label_data = get_document('repository_labels', doc_id) + github_org = repo.split('/')[0] if '/' in repo else None + label_data = get_document('repository_labels', doc_id, github_org=github_org) if label_data and 'labels' in label_data: label_names = [ From 757e5fa6b3b34cf46b8c6c677dcab8957dc4f066 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 16:13:45 +0700 Subject: [PATCH 38/64] fix(firestore): restore notification_config while maintaining SaaS logic --- shared/firestore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/firestore.py b/shared/firestore.py index 161ba3b..9974c71 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -165,6 +165,7 @@ def get_mt_client() -> FirestoreMultiTenant: } GLOBAL_COLLECTIONS = { 'global_config', + 'notification_config', } def get_document(collection: str, document_id: str, discord_server_id: str = None, github_org: str = None) -> Optional[Dict[str, Any]]: From faf7b1ed0b471798ca0b9049fdb99359f8883e38 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 12 Jan 2026 18:13:30 +0700 Subject: [PATCH 39/64] fix: resolve NoneType error in webhook status and revert stylistic renames --- .../src/bot/commands/notification_commands.py | 12 ++++++-- pr_review/main.py | 28 +++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 140ce17..96cab7e 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -222,7 +222,11 @@ async def webhook_status(interaction: discord.Interaction): # Find PR automation webhook for THIS server pr_webhook_entry = next((w for w in webhooks_list if w.get('type') == 'pr_automation' and w.get('server_id') == str(interaction.guild_id)), None) - pr_webhook = pr_webhook_entry['url'] if pr_webhook_entry else webhook_config.get('pr_automation_webhook_url') + pr_webhook = None + if pr_webhook_entry: + pr_webhook = pr_webhook_entry.get('url') + elif webhook_config: + pr_webhook = webhook_config.get('pr_automation_webhook_url') pr_status = "Configured" if pr_webhook else "Not configured" embed.add_field( @@ -233,7 +237,11 @@ async def webhook_status(interaction: discord.Interaction): # Find CI/CD webhook for THIS server cicd_webhook_entry = next((w for w in webhooks_list if w.get('type') == 'cicd' and w.get('server_id') == str(interaction.guild_id)), None) - cicd_webhook = cicd_webhook_entry['url'] if cicd_webhook_entry else webhook_config.get('cicd_webhook_url') + cicd_webhook = None + if cicd_webhook_entry: + cicd_webhook = cicd_webhook_entry.get('url') + elif webhook_config: + cicd_webhook = webhook_config.get('cicd_webhook_url') cicd_status = "Configured" if cicd_webhook else "Not configured" embed.add_field( diff --git a/pr_review/main.py b/pr_review/main.py index 850bc30..02d0abf 100644 --- a/pr_review/main.py +++ b/pr_review/main.py @@ -38,10 +38,10 @@ def __init__(self): """Initialize the PR review system""" try: # Initialize components - self.github = GitHubClient() - self.metrics = MetricsCalculator() - self.labeler = AIPRLabeler() - self.assigner = None # Will be initialized per request + self.github_client = GitHubClient() + self.metrics_calculator = MetricsCalculator() + self.ai_labeler = AIPRLabeler() + self.reviewer_assigner = None # Will be initialized per request logger.info("PR Review System initialized successfully") @@ -66,13 +66,13 @@ async def process_pull_request(self, repo: str, pr_number: int, experience_level logger.info(f"Processing PR #{pr_number} in {repo}") # Step 1: Get PR details and diff - pr_details = self.github.get_pull_request_details(repo, pr_number) - pr_diff = self.github.get_pull_request_diff(repo, pr_number) - pr_files = self.github.get_pull_request_files(repo, pr_number) + pr_details = self.github_client.get_pull_request_details(repo, pr_number) + pr_diff = self.github_client.get_pull_request_diff(repo, pr_number) + pr_files = self.github_client.get_pull_request_files(repo, pr_number) # Step 2: Calculate metrics logger.info("Calculating PR metrics...") - metrics = self.metrics.calculate_pr_metrics(pr_diff, pr_files) + metrics = self.metrics_calculator.calculate_pr_metrics(pr_diff, pr_files) # Step 3: AI-based label prediction logger.info("Predicting labels with AI...") @@ -82,13 +82,13 @@ async def process_pull_request(self, repo: str, pr_number: int, experience_level 'diff': pr_diff, 'metrics': metrics } - predicted_labels = self.labeler.predict_labels(pr_data, repo) + predicted_labels = self.ai_labeler.predict_labels(pr_data, repo) # Step 4: Assign reviewers logger.info("Assigning reviewers...") repo_owner = repo.split('/')[0] if '/' in repo else repo - self.assigner = ReviewerAssigner(github_org=repo_owner) - reviewer_assignments = self.assigner.assign_reviewers(pr_data, repo) + self.reviewer_assigner = ReviewerAssigner(github_org=repo_owner) + reviewer_assignments = self.reviewer_assigner.assign_reviewers(pr_data, repo) # Step 5: Skip AI review generation (not needed per mentor requirements) ai_review = {"summary": "AI review disabled - focusing on metrics and automation"} @@ -98,20 +98,20 @@ async def process_pull_request(self, repo: str, pr_number: int, experience_level label_names = [label['name'] for label in predicted_labels if label['confidence'] >= 0.5] if label_names: logger.info(f"Applying labels: {label_names}") - self.github.add_labels_to_pull_request(repo, pr_number, label_names) + self.github_client.add_labels_to_pull_request(repo, pr_number, label_names) # Step 7: Request reviewers if reviewer_assignments.get('reviewers'): reviewers = [r['username'] for r in reviewer_assignments['reviewers']] logger.info(f"Requesting reviewers: {reviewers}") - self.github.request_reviewers(repo, pr_number, reviewers) + self.github_client.request_reviewers(repo, pr_number, reviewers) # Step 8: Post comprehensive comment comment_body = self._build_comprehensive_comment( metrics, predicted_labels, reviewer_assignments, ai_review ) - self.github.create_issue_comment(repo, pr_number, comment_body) + self.github_client.create_issue_comment(repo, pr_number, comment_body) # Prepare results results = { From 660db09aabd89a2c26213ba9e86060a810e3f578 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 18:05:29 +0700 Subject: [PATCH 40/64] fix: resolve resource leaks and error handling in notification system - Add finally block to close Discord client even if start() fails (auth.py) - Return empty list instead of None in _get_webhook_urls() to prevent TypeError - Track aiohttp session ownership and cleanup when created outside context manager --- discord_bot/src/bot/auth.py | 4 ++++ discord_bot/src/services/notification_service.py | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 596082c..227eedc 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -473,6 +473,10 @@ async def on_ready(): await client.start(token) except Exception as e: print(f"Failed to start Discord client for notification: {e}") + finally: + # Ensure client is closed even if start() fails + if not client.is_closed(): + await client.close() def trigger_data_pipeline_for_org(github_org): # Placeholder for triggering a data pipeline for the given GitHub organization diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index ef4d766..f6075ae 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -235,13 +235,15 @@ async def _get_webhook_urls(self, notification_type: str, github_org: str = None return urls except Exception as e: logger.error(f"Failed to get webhook URL for {notification_type}: {e}") - return None + return [] async def _send_webhook(self, webhook_url: str, payload: Dict[str, Any]) -> bool: """Send payload to Discord webhook.""" + session_created_here = False try: if not self.session: self.session = aiohttp.ClientSession() + session_created_here = True async with self.session.post( webhook_url, @@ -258,6 +260,11 @@ async def _send_webhook(self, webhook_url: str, payload: Dict[str, Any]) -> bool except Exception as e: logger.error(f"Failed to send webhook: {e}") return False + finally: + # Clean up session if we created it here (not using context manager) + if session_created_here and self.session: + await self.session.close() + self.session = None class WebhookManager: """Manages webhook URL configuration and repository monitoring.""" From f68b61733aa2047134505a710cf29e6b502d1d6d Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 18:17:19 +0700 Subject: [PATCH 41/64] refactor: remove on_ready setup reminder and simplify footer - Remove _check_server_configurations() from on_ready (bots shouldn't message on redeploy) - Keep on_guild_join handler for first-time setup guidance (user-initiated action) - Change footer from 'Powered by DisgitBot SaaS' to 'Powered by DisgitBot' --- discord_bot/src/bot/auth.py | 2 +- discord_bot/src/bot/bot.py | 69 ------------------------------------- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 227eedc..8eca151 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -459,7 +459,7 @@ async def on_ready(): color=0x43b581 ) embed.add_field(name="Next Steps", value="1. Use `/link` to connect your GitHub account\n2. Configure webhooks with `/set_webhook`", inline=False) - embed.set_footer(text="Powered by DisgitBot SaaS") + embed.set_footer(text="Powered by DisgitBot") await channel.send(embed=embed) print(f"Sent setup success notification to guild {guild_id}") diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index 59d9b84..3305e6f 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -50,9 +50,6 @@ async def on_ready(): 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"Error in on_ready: {e}") import traceback @@ -125,73 +122,7 @@ async def on_guild_join(guild): import traceback traceback.print_exc() - async 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)) or {} - - if not server_config.get('setup_completed'): - # Check if we sent a reminder very recently (24h cooldown) - last_reminder = server_config.get('setup_reminder_sent_at') - if last_reminder: - from datetime import datetime, timedelta - try: - last_dt = datetime.fromisoformat(last_reminder) - if datetime.now() - last_dt < timedelta(hours=24): - print(f"Skipping setup reminder for {guild.name}: already sent within 24h") - continue - except ValueError: - pass - - # 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") - 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** -This server needs to be configured to track GitHub contributions. - -**Quick Setup (30 seconds):** -1. Visit: {setup_url} -2. Install the GitHub App and select repositories -3. Use `/link` in Discord to connect GitHub accounts -4. Customize roles with `/configure roles` - -**Or use this command:** `/setup` - -*This is a one-time setup message.*""" - - await system_channel.send(setup_message) - - # Mark reminder as sent - from datetime import datetime - mt_client.set_server_config(str(guild.id), { - **server_config, - 'setup_reminder_sent_at': datetime.now().isoformat() - }) - print(f"Sent setup reminder to server: {guild.name} (ID: {guild.id})") - - # Run the async function directly - await 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.""" user_commands = UserCommands(self.bot) From 0e8301921f625eebc52f2c9eb016f5cb94bbc3a6 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 18:47:54 +0700 Subject: [PATCH 42/64] fix: validate setup before webhooks and show per-server timestamps - /set_webhook now requires setup_completed before allowing configuration - /webhook_status shows per-server webhook timestamps instead of org-level - Prevents confusing behavior when old data exists from previous setup --- .../src/bot/commands/notification_commands.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 96cab7e..c1f3df0 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -39,6 +39,18 @@ async def set_webhook( await interaction.response.defer(ephemeral=True) try: + # Check if setup is completed first + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(interaction.guild_id)) or {} + + if not server_config.get('setup_completed'): + await interaction.followup.send( + "⚠️ Please complete `/setup` first before configuring webhooks.", + ephemeral=True + ) + return + # Validate webhook URL format if not self._is_valid_webhook_url(webhook_url): await interaction.followup.send( @@ -250,11 +262,18 @@ async def webhook_status(interaction: discord.Interaction): inline=True ) - # Last updated - if webhook_config and webhook_config.get('last_updated'): + # Last updated - show most recent webhook update for THIS server + webhook_updates = [] + if pr_webhook_entry and pr_webhook_entry.get('last_updated'): + webhook_updates.append(pr_webhook_entry['last_updated']) + if cicd_webhook_entry and cicd_webhook_entry.get('last_updated'): + webhook_updates.append(cicd_webhook_entry['last_updated']) + + if webhook_updates: + latest_update = max(webhook_updates) embed.add_field( name="Last Updated", - value=webhook_config['last_updated'], + value=latest_update, inline=False ) From cecaf747e6f3904cfcf3e1376781ddfeea1d7624 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 20:54:51 +0700 Subject: [PATCH 43/64] feat: add GitHub webhook handler for SaaS PR automation --- discord_bot/config/.env.example | 1 + discord_bot/src/bot/auth.py | 123 +++++++++++++++++- .../src/bot/commands/notification_commands.py | 2 +- 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index 5f7e052..b6d6327 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -9,3 +9,4 @@ GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY_B64= GITHUB_APP_SLUG= ADMIN_TOKEN= +GITHUB_WEBHOOK_SECRET= diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 8eca151..7b65c9b 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,8 +1,10 @@ import os import threading import time +import hmac +import hashlib import requests -from flask import Flask, redirect, url_for, jsonify, session +from flask import Flask, redirect, url_for, jsonify, session, request from flask_dance.contrib.github import make_github_blueprint, github from dotenv import load_dotenv from werkzeug.middleware.proxy_fix import ProxyFix @@ -53,7 +55,8 @@ def index(): "setup": "/setup", "github_auth": "/auth/start/", "github_app_install": "/github/app/install", - "github_app_setup_callback": "/github/app/setup" + "github_app_setup_callback": "/github/app/setup", + "github_webhook": "/github/webhook" } }) @@ -87,6 +90,116 @@ def debug_servers(): except Exception as e: return jsonify({"error": str(e)}), 500 + @app.route("/github/webhook", methods=["POST"]) + def github_webhook(): + """ + GitHub webhook endpoint for SaaS PR automation. + Processes pull_request events from any org that installs the GitHub App. + """ + import asyncio + from threading import Thread + + # 1. Verify webhook signature + webhook_secret = os.getenv("GITHUB_WEBHOOK_SECRET") + if not webhook_secret: + print("WARNING: GITHUB_WEBHOOK_SECRET not set, skipping signature verification") + else: + signature = request.headers.get("X-Hub-Signature-256") + if not signature: + print("Missing X-Hub-Signature-256 header") + return jsonify({"error": "Missing signature"}), 401 + + expected_signature = "sha256=" + hmac.new( + webhook_secret.encode(), + request.data, + hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(signature, expected_signature): + print("Invalid webhook signature") + return jsonify({"error": "Invalid signature"}), 401 + + print("Signature verified successfully") + + # 2. Parse event type + event_type = request.headers.get("X-GitHub-Event") + delivery_id = request.headers.get("X-GitHub-Delivery") + + print(f"Received webhook: event={event_type}, delivery_id={delivery_id}") + + if event_type == "ping": + return jsonify({"message": "pong", "delivery_id": delivery_id}), 200 + + # 3. Handle pull_request events + if event_type != "pull_request": + print(f"Ignoring event type: {event_type}") + return jsonify({"message": f"Ignored event: {event_type}"}), 200 + + try: + payload = request.get_json() + action = payload.get("action") + + # Only process opened and synchronize (push to PR) actions + if action not in ["opened", "synchronize", "reopened"]: + print(f"Ignoring PR action: {action}") + return jsonify({"message": f"Ignored action: {action}"}), 200 + + pr = payload.get("pull_request", {}) + repo = payload.get("repository", {}) + + pr_number = pr.get("number") + repo_full_name = repo.get("full_name") # e.g., "owner/repo" + + if not pr_number or not repo_full_name: + return jsonify({"error": "Missing PR number or repo"}), 400 + + print(f"Processing PR #{pr_number} in {repo_full_name} (action: {action})") + + # 4. Trigger PR automation in background thread + def run_pr_automation(): + import sys + from pathlib import Path + + # Add pr_review to path + pr_review_path = Path(__file__).parent.parent.parent.parent.parent / "pr_review" + if str(pr_review_path) not in sys.path: + sys.path.insert(0, str(pr_review_path)) + + try: + from main import PRReviewSystem + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + system = PRReviewSystem() + results = loop.run_until_complete( + system.process_pull_request(repo_full_name, pr_number) + ) + + print(f"PR automation completed: {results.get('status', 'unknown')}") + loop.close() + + except Exception as e: + print(f"PR automation failed: {e}") + import traceback + traceback.print_exc() + + # Start background thread for PR processing + Thread(target=run_pr_automation, daemon=True).start() + + return jsonify({ + "message": "PR automation triggered", + "pr_number": pr_number, + "repository": repo_full_name, + "action": action + }), 202 + + except Exception as e: + print(f"Error processing webhook: {e}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + @app.route("/invite") def invite_bot(): """Discord bot invitation endpoint""" @@ -454,7 +567,7 @@ async def on_ready(): if channel: embed = discord.Embed( - title="✅ DisgitBot Setup Complete!", + title="DisgitBot Setup Complete!", description=f"This server is now connected to the GitHub organization: **{github_org}**", color=0x43b581 ) @@ -528,9 +641,9 @@ def trigger_data_pipeline_for_org(github_org):

2) Configure custom roles:

/configure roles
{% if sync_triggered %} -

✅ Initial sync started. Stats will appear shortly.

+

Initial sync started. Stats will appear shortly.

{% else %} -

⏳ Initial sync will run on the next scheduled pipeline.

+

Initial sync will run on the next scheduled pipeline.

{% endif %}

3) Try these commands:

/getstats
diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index c1f3df0..a3da15b 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -46,7 +46,7 @@ async def set_webhook( if not server_config.get('setup_completed'): await interaction.followup.send( - "⚠️ Please complete `/setup` first before configuring webhooks.", + "Please complete `/setup` first before configuring webhooks.", ephemeral=True ) return From 0a94ccd9d791f3fb7a1f923c7d4af9b4f0747100 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 21:47:40 +0700 Subject: [PATCH 44/64] fix: include pr_review in Docker build for webhook automation --- discord_bot/deployment/Dockerfile | 3 +++ discord_bot/deployment/deploy.sh | 13 +++++++++++++ discord_bot/src/bot/auth.py | 10 +--------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/discord_bot/deployment/Dockerfile b/discord_bot/deployment/Dockerfile index be0e20c..b432663 100644 --- a/discord_bot/deployment/Dockerfile +++ b/discord_bot/deployment/Dockerfile @@ -27,6 +27,9 @@ RUN mkdir -p /app/config && echo "{}" > /app/config/credentials.json # Copy shared package first (copied into build context by deploy script) COPY shared ./shared +# Copy pr_review package (copied into build context by deploy script) +COPY pr_review ./pr_review + # Set PYTHONPATH to include shared packages (needed for from shared.firestore import ...) ENV PYTHONPATH=/app:$PYTHONPATH diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 96842f7..c5b5fed 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -727,6 +727,15 @@ main() { print_warning "Shared directory not found - skipping shared copy" fi + # Copy pr_review directory into build context for PR automation + print_step "Copying pr_review directory into build context..." + if [ -d "$(dirname "$ROOT_DIR")/pr_review" ]; then + cp -r "$(dirname "$ROOT_DIR")/pr_review" "$ROOT_DIR/pr_review" + print_success "pr_review directory copied successfully" + else + print_warning "pr_review directory not found - skipping pr_review copy" + fi + # Use Cloud Build to build and push the image gcloud builds submit \ --tag gcr.io/$PROJECT_ID/$SERVICE_NAME:latest \ @@ -739,6 +748,10 @@ main() { rm -rf "$ROOT_DIR/shared" print_step "Cleaned up temporary shared directory" fi + if [ -d "$ROOT_DIR/pr_review" ]; then + rm -rf "$ROOT_DIR/pr_review" + print_step "Cleaned up temporary pr_review directory" + fi print_success "Build completed and temporary files cleaned up!" # Clean up existing service configuration if exists diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 7b65c9b..1d5ee92 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -157,16 +157,8 @@ def github_webhook(): # 4. Trigger PR automation in background thread def run_pr_automation(): - import sys - from pathlib import Path - - # Add pr_review to path - pr_review_path = Path(__file__).parent.parent.parent.parent.parent / "pr_review" - if str(pr_review_path) not in sys.path: - sys.path.insert(0, str(pr_review_path)) - try: - from main import PRReviewSystem + from pr_review.main import PRReviewSystem loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) From ae4b95b4310d612f51f0f073284c2a66d1462c55 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 21:56:16 +0700 Subject: [PATCH 45/64] fix: use try/except import pattern for pr_review package compatibility --- pr_review/main.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pr_review/main.py b/pr_review/main.py index 02d0abf..87a9de3 100644 --- a/pr_review/main.py +++ b/pr_review/main.py @@ -16,12 +16,22 @@ if str(root_dir) not in sys.path: sys.path.append(str(root_dir)) -from config import GITHUB_TOKEN, GOOGLE_API_KEY, REPO_OWNER -from utils.github_client import GitHubClient -from utils.metrics_calculator import MetricsCalculator -from utils.ai_pr_labeler import AIPRLabeler -from utils.reviewer_assigner import ReviewerAssigner -from utils.design_formatter import format_design_analysis, format_metrics_summary +try: + # When run as a package (from pr_review.main import ...) + from pr_review.config import GITHUB_TOKEN, GOOGLE_API_KEY, REPO_OWNER + from pr_review.utils.github_client import GitHubClient + from pr_review.utils.metrics_calculator import MetricsCalculator + from pr_review.utils.ai_pr_labeler import AIPRLabeler + from pr_review.utils.reviewer_assigner import ReviewerAssigner + from pr_review.utils.design_formatter import format_design_analysis, format_metrics_summary +except ImportError: + # When run standalone (python main.py) + from config import GITHUB_TOKEN, GOOGLE_API_KEY, REPO_OWNER + from utils.github_client import GitHubClient + from utils.metrics_calculator import MetricsCalculator + from utils.ai_pr_labeler import AIPRLabeler + from utils.reviewer_assigner import ReviewerAssigner + from utils.design_formatter import format_design_analysis, format_metrics_summary # Configure logging From cba969bb655542259c57ca6761bc2c0daaae54af Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 22:10:49 +0700 Subject: [PATCH 46/64] fix: install pr_review dependencies from its own requirements.txt --- discord_bot/deployment/Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/discord_bot/deployment/Dockerfile b/discord_bot/deployment/Dockerfile index b432663..4c97fc5 100644 --- a/discord_bot/deployment/Dockerfile +++ b/discord_bot/deployment/Dockerfile @@ -16,10 +16,14 @@ RUN apt-get update && \ # Copy requirements first to leverage Docker cache COPY requirements.txt . +# Copy pr_review package (copied into build context by deploy script) +COPY pr_review ./pr_review + # Upgrade pip to latest version to avoid upgrade notices RUN pip install --upgrade pip -RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt +# Install dependencies from both requirements files +RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt -r pr_review/requirements.txt # Create config directory and empty credentials file (will be overwritten by volume mount) RUN mkdir -p /app/config && echo "{}" > /app/config/credentials.json @@ -27,9 +31,6 @@ RUN mkdir -p /app/config && echo "{}" > /app/config/credentials.json # Copy shared package first (copied into build context by deploy script) COPY shared ./shared -# Copy pr_review package (copied into build context by deploy script) -COPY pr_review ./pr_review - # Set PYTHONPATH to include shared packages (needed for from shared.firestore import ...) ENV PYTHONPATH=/app:$PYTHONPATH From a750fd1a014c33fa03e58c215d647a90a723255c Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Tue, 13 Jan 2026 22:45:56 +0700 Subject: [PATCH 47/64] fix: update all pr_review utils to use try/except import pattern --- pr_review/utils/base_ai_analyzer.py | 6 +++++- pr_review/utils/github_client.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pr_review/utils/base_ai_analyzer.py b/pr_review/utils/base_ai_analyzer.py index 7edac0f..6f4790e 100644 --- a/pr_review/utils/base_ai_analyzer.py +++ b/pr_review/utils/base_ai_analyzer.py @@ -7,7 +7,11 @@ import json from typing import Dict, Any, List import google.generativeai as genai -from config import GOOGLE_API_KEY + +try: + from pr_review.config import GOOGLE_API_KEY +except ImportError: + from config import GOOGLE_API_KEY logger = logging.getLogger(__name__) diff --git a/pr_review/utils/github_client.py b/pr_review/utils/github_client.py index 0941dd8..23ca930 100644 --- a/pr_review/utils/github_client.py +++ b/pr_review/utils/github_client.py @@ -8,7 +8,11 @@ import logging from typing import List, Dict, Any, Optional from github import Github -from config import GITHUB_TOKEN + +try: + from pr_review.config import GITHUB_TOKEN +except ImportError: + from config import GITHUB_TOKEN class GitHubClient: """GitHub API client for PR review system""" From 36c0acce840b69d7caf583d19fafba99ecaba601 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 30 Jan 2026 12:47:54 +0700 Subject: [PATCH 48/64] fix: prevent OAuth session memory leak and extend setup state expiration - Add background thread to clean up abandoned OAuth sessions (>10 min) - Extend setup state token expiration from 30min to 7 days for org approval - Disable PR automation at webhook handler level (returns 501) --- discord_bot/src/bot/auth.py | 55 +++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 1d5ee92..8286ec6 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -16,6 +16,25 @@ oauth_sessions = {} oauth_sessions_lock = threading.Lock() +# Background thread to clean up old OAuth sessions (prevents memory leak) +def cleanup_old_oauth_sessions(): + """Clean up OAuth sessions older than 10 minutes to prevent memory leak.""" + while True: + time.sleep(300) # Check every 5 minutes + with oauth_sessions_lock: + current_time = time.time() + expired_sessions = [ + user_id for user_id, session_data in oauth_sessions.items() + if current_time - session_data.get('created_at', current_time) > 600 # 10 min + ] + for user_id in expired_sessions: + del oauth_sessions[user_id] + print(f"Cleaned up expired OAuth session for user {user_id}") + +# Start cleanup thread +_cleanup_thread = threading.Thread(target=cleanup_old_oauth_sessions, daemon=True) +_cleanup_thread.start() + def create_oauth_app(): """ Create and configure the Flask OAuth application. @@ -99,27 +118,23 @@ def github_webhook(): import asyncio from threading import Thread - # 1. Verify webhook signature + # PR automation is disabled - /set_webhook command removed + # To re-enable: restore /set_webhook command in notification_commands.py + print("PR automation is disabled (feature removed)") + return jsonify({ + "message": "PR automation is not available", + "status": "not_implemented" + }), 501 + + # NOTE: Code below is kept for future re-enablement + # 1. Verify webhook signature (MANDATORY) webhook_secret = os.getenv("GITHUB_WEBHOOK_SECRET") if not webhook_secret: - print("WARNING: GITHUB_WEBHOOK_SECRET not set, skipping signature verification") - else: - signature = request.headers.get("X-Hub-Signature-256") - if not signature: - print("Missing X-Hub-Signature-256 header") - return jsonify({"error": "Missing signature"}), 401 - - expected_signature = "sha256=" + hmac.new( - webhook_secret.encode(), - request.data, - hashlib.sha256 - ).hexdigest() - - if not hmac.compare_digest(signature, expected_signature): - print("Invalid webhook signature") - return jsonify({"error": "Invalid signature"}), 401 - - print("Signature verified successfully") + print("ERROR: GITHUB_WEBHOOK_SECRET not configured - rejecting webhook") + return jsonify({ + "error": "Webhook not configured", + "message": "GITHUB_WEBHOOK_SECRET environment variable must be set" + }), 500 # 2. Parse event type event_type = request.headers.get("X-GitHub-Event") @@ -432,7 +447,7 @@ def github_app_setup(): return "Missing installation_id or state", 400 try: - payload = state_serializer.loads(state, max_age=60 * 30) + payload = state_serializer.loads(state, max_age=60 * 60 * 24 * 7) # 7 days for org approval except SignatureExpired: return "Setup link expired. Please restart setup from Discord.", 400 except BadSignature: From 02830f6e07b7532b3824caf8ddcbef298f08fd17 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Fri, 30 Jan 2026 12:48:09 +0700 Subject: [PATCH 49/64] refactor: remove /set_webhook command (PR automation disabled) - Remove /set_webhook command to clean up UI - Keep other CI/CD related commands (add_repo, remove_repo, list_repos) - PR automation code preserved in auth.py for future re-enable --- .../src/bot/commands/notification_commands.py | 64 +------------------ 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index a3da15b..79f7ab5 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -18,72 +18,14 @@ def __init__(self, bot): def register_commands(self): """Register all notification commands with the bot.""" - self.bot.tree.add_command(self._set_webhook_command()) + # Note: /set_webhook removed - PR automation disabled self.bot.tree.add_command(self._add_repo_command()) self.bot.tree.add_command(self._remove_repo_command()) self.bot.tree.add_command(self._list_repos_command()) self.bot.tree.add_command(self._webhook_status_command()) - def _set_webhook_command(self): - """Create the set_webhook command.""" - @app_commands.command(name="set_webhook", description="Set Discord webhook URL for notifications") - @app_commands.describe( - notification_type="Type of notifications", - webhook_url="Discord webhook URL" - ) - async def set_webhook( - interaction: discord.Interaction, - notification_type: Literal["pr_automation", "cicd"], - webhook_url: str - ): - await interaction.response.defer(ephemeral=True) - - try: - # Check if setup is completed first - from shared.firestore import get_mt_client - mt_client = get_mt_client() - server_config = mt_client.get_server_config(str(interaction.guild_id)) or {} - - if not server_config.get('setup_completed'): - await interaction.followup.send( - "Please complete `/setup` first before configuring webhooks.", - ephemeral=True - ) - return - - # Validate webhook URL format - if not self._is_valid_webhook_url(webhook_url): - await interaction.followup.send( - "Invalid webhook URL format. Please provide a valid Discord webhook URL.", - ephemeral=True - ) - return - - # Set the webhook URL - success = WebhookManager.set_webhook_url( - notification_type, - webhook_url, - discord_server_id=str(interaction.guild_id) - ) - - if success: - await interaction.followup.send( - f"Successfully configured {notification_type} webhook URL.", - ephemeral=True - ) - else: - await interaction.followup.send( - "Failed to save webhook configuration. Please try again.", - ephemeral=True - ) - - except Exception as e: - await interaction.followup.send(f"Error setting webhook: {str(e)}", ephemeral=True) - print(f"Error in set_webhook: {e}") - import traceback - traceback.print_exc() - - return set_webhook + # /set_webhook command removed - PR automation feature disabled + # To re-enable, restore the _set_webhook_command method and register it above def _add_repo_command(self): """Create the add_repo command.""" From e0456a093fcc53e65284e1fd0246757dffb218a0 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 31 Jan 2026 04:07:12 +0700 Subject: [PATCH 50/64] feat: role hierarchy validation + hide PR automation commands --- discord_bot/src/bot/commands/admin_commands.py | 5 +++-- discord_bot/src/bot/commands/config_commands.py | 11 +++++++++++ discord_bot/src/bot/commands/notification_commands.py | 5 +++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 259308a..199e10e 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -19,8 +19,9 @@ def register_commands(self): 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()) + # PR automation commands disabled - keeping code for future re-enablement + # self.bot.tree.add_command(self._add_reviewer_command()) + # self.bot.tree.add_command(self._remove_reviewer_command()) self.bot.tree.add_command(self._list_reviewers_command()) def _check_permissions_command(self): diff --git a/discord_bot/src/bot/commands/config_commands.py b/discord_bot/src/bot/commands/config_commands.py index b5d1491..0685464 100644 --- a/discord_bot/src/bot/commands/config_commands.py +++ b/discord_bot/src/bot/commands/config_commands.py @@ -100,6 +100,17 @@ async def configure_roles( await interaction.followup.send("Threshold must be a positive number.", ephemeral=True) return + # Role hierarchy validation: bot must be able to manage this role + bot_member = guild.get_member(self.bot.user.id) + if bot_member and bot_member.top_role.position <= role.position: + await interaction.followup.send( + f"❌ Cannot add rule for @{role.name}.\n" + f"This role is positioned **equal to or higher** than my top role (@{bot_member.top_role.name}).\n" + f"Please move my role higher in Server Settings → Roles, or choose a lower role.", + ephemeral=True + ) + return + metric_key = metric.value rules = role_rules.get(metric_key, []) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 79f7ab5..62e9323 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -18,11 +18,12 @@ def __init__(self, bot): def register_commands(self): """Register all notification commands with the bot.""" - # Note: /set_webhook removed - PR automation disabled + # CI/CD monitoring commands (still useful) self.bot.tree.add_command(self._add_repo_command()) self.bot.tree.add_command(self._remove_repo_command()) self.bot.tree.add_command(self._list_repos_command()) - self.bot.tree.add_command(self._webhook_status_command()) + # PR automation commands disabled - keeping code for future re-enablement + # self.bot.tree.add_command(self._webhook_status_command()) # /set_webhook command removed - PR automation feature disabled # To re-enable, restore the _set_webhook_command method and register it above From 54b09d268f936869fbed4d0f76c0f2f691ddac13 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Mon, 9 Feb 2026 16:09:07 +0700 Subject: [PATCH 51/64] style: premium UI redesign for all setup flow pages with elegant 500px width --- discord_bot/README.md | 48 +- discord_bot/config/.env.example | 6 +- discord_bot/deployment/deploy.sh | 16 - discord_bot/src/bot/auth.py | 891 +++++++++++++++++-------- discord_bot/src/utils/env_validator.py | 25 +- 5 files changed, 631 insertions(+), 355 deletions(-) diff --git a/discord_bot/README.md b/discord_bot/README.md index 6ed7753..f4e73ee 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -124,14 +124,13 @@ 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) +- `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) +- `DISCORD_BOT_CLIENT_ID=` (Discord application ID) - `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) -- `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) @@ -139,9 +138,7 @@ 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` - `GOOGLE_CREDENTIALS_JSON` -- `REPO_OWNER` - `CLOUD_RUN_URL` - `GH_APP_ID` - `GH_APP_PRIVATE_KEY_B64` @@ -150,7 +147,6 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - `DEV_GOOGLE_CREDENTIALS_JSON` - `DEV_CLOUD_RUN_URL` -> The workflows only reference `GH_TOKEN`, so you can reuse the same PAT for all branches. --- @@ -250,25 +246,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - **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) - -**What this configures:** -- `.env` file: `GITHUB_TOKEN=your_token_here` -- GitHub Secret: `GH_TOKEN` - -**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:** - - Click "Generate new token" → "Generate new token (classic)" -3. **Set Permissions:** - - Check only: [x] `repo` (this gives full repository access) -4. **Generate and Save:** - - Click "Generate token" → Copy the token - - **Add to `.env`:** `GITHUB_TOKEN=your_token_here` - - **Add to GitHub Secrets:** Create secret named `GH_TOKEN` - -### Step 4: Get Cloud Run URL (Placeholder Deployment) +### Step 3: Get Cloud Run URL (Placeholder Deployment) **What this configures:** - `.env` file: `OAUTH_BASE_URL=YOUR_CLOUD_RUN_URL` @@ -305,7 +283,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - **Example:** `https://discord-bot-abcd1234-uc.a.run.app/setup` - Click **Save Changes** -### Step 5: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) +### Step 4: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) **What this configures:** - `.env` file: `GITHUB_CLIENT_ID=your_client_id` @@ -331,7 +309,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - 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) +### Step 5: 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=...` @@ -368,21 +346,6 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **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:** -- `.env` file: `REPO_OWNER=your_org_name` -- GitHub Secret: `REPO_OWNER` - -**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 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_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 --- @@ -770,7 +733,6 @@ async def link(interaction: discord.Interaction): # Check required environment variables required_vars = [ "DISCORD_BOT_TOKEN", - "GITHUB_TOKEN", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "OAUTH_BASE_URL" # ← This is your Cloud Run URL diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index b6d6327..71aee17 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -1,12 +1,8 @@ DISCORD_BOT_TOKEN= -GITHUB_TOKEN= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -REPO_OWNER= OAUTH_BASE_URL= DISCORD_BOT_CLIENT_ID= GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY_B64= -GITHUB_APP_SLUG= -ADMIN_TOKEN= -GITHUB_WEBHOOK_SECRET= +GITHUB_APP_SLUG= \ No newline at end of file diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index c5b5fed..4ac3f40 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -333,18 +333,12 @@ create_new_env_file() { print_warning "Discord Bot 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 # GitHub Client Secret read -p "GitHub Client Secret: " github_client_secret - # Repository Owner - read -p "Repository Owner: " repo_owner - # OAuth Base URL (optional - will auto-detect on Cloud Run) read -p "OAuth Base URL (optional): " oauth_base_url @@ -359,10 +353,8 @@ create_new_env_file() { # Create .env file cat > "$ENV_PATH" << EOF DISCORD_BOT_TOKEN=$discord_token -GITHUB_TOKEN=$github_token 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 @@ -383,18 +375,12 @@ edit_env_file() { read -p "Discord Bot Token [$DISCORD_BOT_TOKEN]: " new_discord_token discord_token=${new_discord_token:-$DISCORD_BOT_TOKEN} - read -p "GitHub Token [$GITHUB_TOKEN]: " new_github_token - github_token=${new_github_token:-$GITHUB_TOKEN} - read -p "GitHub Client ID [$GITHUB_CLIENT_ID]: " new_github_client_id github_client_id=${new_github_client_id:-$GITHUB_CLIENT_ID} read -p "GitHub Client Secret [$GITHUB_CLIENT_SECRET]: " new_github_client_secret github_client_secret=${new_github_client_secret:-$GITHUB_CLIENT_SECRET} - read -p "Repository Owner [$REPO_OWNER]: " new_repo_owner - repo_owner=${new_repo_owner:-$REPO_OWNER} - read -p "OAuth Base URL [$OAUTH_BASE_URL]: " new_oauth_base_url oauth_base_url=${new_oauth_base_url:-$OAUTH_BASE_URL} @@ -413,10 +399,8 @@ edit_env_file() { # Update .env file cat > "$ENV_PATH" << EOF DISCORD_BOT_TOKEN=$discord_token -GITHUB_TOKEN=$github_token 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 diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 8286ec6..a8aad4d 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -78,36 +78,6 @@ def index(): "github_webhook": "/github/webhook" } }) - - @app.route("/debug/servers") - def debug_servers(): - """Debug endpoint to see registered servers (Protected)""" - admin_token = os.getenv("ADMIN_TOKEN") - if not admin_token or request.args.get("token") != admin_token: - return jsonify({"error": "Unauthorized"}), 401 - try: - from shared.firestore import get_mt_client - - mt_client = get_mt_client() - - # Get all servers - servers_ref = mt_client.db.collection('discord_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("/github/webhook", methods=["POST"]) def github_webhook(): @@ -210,13 +180,11 @@ def run_pr_automation(): @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 = ( @@ -227,96 +195,215 @@ def invite_bot(): f"scope=bot+applications.commands" ) - # Enhanced landing page with clear instructions + setup_url = f"{base_url}/setup" + + # Enhanced landing page with modern design 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 DisgitBot to Discord + + + + + + + + + +
+

Add DisgitBot

+

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

+ + + + + + Add to Discord + + +
+
Setup Required After Adding
+ +
+
1
+
+ Authorize Bot: Click the button above to add the bot to your server.
+
-

Features:

-
- Real-time GitHub statistics +
+
2
+
+ Configuration: Visit the setup dashboard:
+ {setup_url}
-
- Automated role assignment -
-
- Contribution analytics & charts +
+ +
+
3
+
+ Install GitHub App: Select which repositories you want to track.
-
- Auto-updating voice channels +
+ +
+
4
+
+ Link Accounts: Users can run /link in Discord.
- -

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

- - - """ +
+ +
+
📊 Real-time Stats
+
🤖 Auto Roles
+
📈 Analytics Charts
+
🔊 Voice Updates
+
+
+ + +""" - return render_template_string(landing_page, discord_invite_url=discord_invite_url) + return landing_page @app.route("/auth/start/") def start_oauth(discord_user_id): @@ -527,83 +614,12 @@ def trigger_initial_sync(org_name: str) -> bool: **existing_config, "initial_sync_triggered_at": datetime.now().isoformat() }) - if github_org: - try: - # Trigger Discord notification - import asyncio - from threading import Thread - - def run_async_notification(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(send_discord_setup_notification(guild_id, github_org)) - loop.close() - - Thread(target=run_async_notification).start() - - # Trigger initial data collection for this organization - trigger_data_pipeline_for_org(github_org) - except Exception as e: - print(f"Warning: Failed to trigger setup notifications: {e}") 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 - async def send_discord_setup_notification(guild_id: str, github_org: str): - """Send a success message to the Discord guild's system channel.""" - import discord - import os - - token = os.getenv('DISCORD_BOT_TOKEN') - if not token: - return - - intents = discord.Intents.default() - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - try: - guild = client.get_guild(int(guild_id)) - if guild: - channel = guild.system_channel - if not channel: - channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) - - if channel: - embed = discord.Embed( - title="DisgitBot Setup Complete!", - description=f"This server is now connected to the GitHub organization: **{github_org}**", - color=0x43b581 - ) - embed.add_field(name="Next Steps", value="1. Use `/link` to connect your GitHub account\n2. Configure webhooks with `/set_webhook`", inline=False) - embed.set_footer(text="Powered by DisgitBot") - - await channel.send(embed=embed) - print(f"Sent setup success notification to guild {guild_id}") - - except Exception as e: - print(f"Error sending Discord setup notification: {e}") - finally: - await client.close() - - try: - await client.start(token) - except Exception as e: - print(f"Failed to start Discord client for notification: {e}") - finally: - # Ensure client is closed even if start() fails - if not client.is_closed(): - await client.close() - - def trigger_data_pipeline_for_org(github_org): - # Placeholder for triggering a data pipeline for the given GitHub organization - # This would typically involve calling an external service or another part of the system - print(f"Triggering data pipeline for GitHub organization: {github_org}") - # Example: You might want to add a task to a queue here - pass sync_triggered = trigger_initial_sync(github_org) @@ -611,50 +627,168 @@ def trigger_data_pipeline_for_org(github_org): - GitHub Connected! + Setup Completed!d! + + +
-

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. +

+
+ +

Success!

+
+

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

+
+ +
+ +
+

Next Steps in Discord

+ +
+ 1. Users link accounts + /link +
+ +
+ 2. View stats + /getstats +
+ +
+ + {% if sync_triggered %} + Data sync started. Stats appearing shortly. + {% else %} + Sync scheduled. Contributions ready soon. + {% endif %} +
+
+ + - {% endif %} - -

Next Steps in Discord

-

1) Users link their GitHub accounts:

-
/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
@@ -690,53 +824,143 @@ def setup(): DisgitBot Setup + + +
-

DisgitBot Added Successfully!

-

Bot has been added to {{ guild_name }}

- -

Recommended: Install the GitHub App

-

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

- Install GitHub App +
+

DisgitBot Added!

+

Bot has been successfully added to {{ guild_name }}

+
-

Manual Setup (disabled)

-

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

+
-

+

+

Install the GitHub App

+

Required: Select which repositories you want the bot to track.

+ + + + + + Install GitHub App + +
+ +
@@ -789,42 +1013,165 @@ def complete_setup(): - Setup Complete! + Setup Completed!d! + + +
-

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
+
+
+ +

Success!

+
+

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

+
+ +
+ +
+

Next Steps in Discord

+ +
+ 1. Users link accounts + /link +
+ +
+ 2. View stats + /getstats +
+ +
+ + Data sync started. Stats appearing shortly. +
+
-

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

diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 62e5513..4063d8c 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -35,11 +35,6 @@ 'required': True, 'description': 'Discord bot token for authentication' }, - 'GITHUB_TOKEN': { - '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, 'description': 'GitHub OAuth application client ID' @@ -48,32 +43,24 @@ 'required': True, 'description': 'GitHub OAuth application client secret' }, - 'REPO_OWNER': { - 'required': True, - 'description': 'GitHub repository owner/organization name' - }, 'OAUTH_BASE_URL': { - '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)' + 'required': True, + 'description': 'Base URL for OAuth redirects (your Cloud Run URL)' }, '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)' + 'required': True, + 'description': 'GitHub App ID (required for SaaS mode)' }, '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.', + 'required': True, '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.', + 'required': True, 'description': 'GitHub App slug (the /apps/ part)' } } From 46127eb6da6d3967582c672bde45aa9a0be0cc56 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Thu, 12 Feb 2026 09:38:43 +0700 Subject: [PATCH 52/64] feat: add cross-thread communication infrastructure - Add shared.py module for global bot_instance reference - Add notify_setup_complete with asyncio.run_coroutine_threadsafe - Add trigger_initial_sync with GitHub App identity - Add find_installation_id to GitHubAppService for auto-discovery - Integrate setup notification and initial sync into complete_setup flow --- discord_bot/src/bot/auth.py | 437 +++++++++++------- discord_bot/src/bot/shared.py | 7 + .../src/services/github_app_service.py | 21 +- 3 files changed, 290 insertions(+), 175 deletions(-) create mode 100644 discord_bot/src/bot/shared.py diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index a8aad4d..201e522 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,4 +1,7 @@ import os +from typing import Optional +from datetime import datetime, timedelta +from shared.firestore import get_mt_client import threading import time import hmac @@ -31,6 +34,114 @@ def cleanup_old_oauth_sessions(): del oauth_sessions[user_id] print(f"Cleaned up expired OAuth session for user {user_id}") +def notify_setup_complete(guild_id: str, github_org: str): + """Send a success message to the Discord guild's system channel instantly.""" + from . import shared + import discord + + if not shared.bot_instance or not shared.bot_instance.bot: + print(f"Warning: Cannot send setup notification to {guild_id} - bot instance not ready") + return + + bot = shared.bot_instance.bot + + async def send_msg(): + try: + guild = bot.get_guild(int(guild_id)) + if not guild: + # Try to fetch if not in cache + guild = await bot.fetch_guild(int(guild_id)) + + if guild: + channel = guild.system_channel + if not channel: + channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) + + if channel: + embed = discord.Embed( + title="✅ DisgitBot Setup Complete!", + description=f"This server is now connected to the GitHub organization: **{github_org}**", + color=0x43b581 + ) + embed.add_field( + name="Next Steps", + value="1. Use `/link` to connect your GitHub account\n2. Customize roles with `/configure roles`", + inline=False + ) + embed.set_footer(text="Powered by DisgitBot") + + await channel.send(embed=embed) + print(f"Sent setup success notification to guild {guild_id}") + except Exception as e: + print(f"Error sending Discord setup notification: {e}") + + # Schedule the coroutine in the bot's event loop (thread-safe) + import asyncio + asyncio.run_coroutine_threadsafe(send_msg(), bot.loop) + +def trigger_initial_sync(guild_id: str, org_name: str, installation_id: Optional[int] = None) -> bool: + """Trigger the GitHub Actions pipeline using GitHub App identity.""" + from src.services.github_app_service import GitHubAppService + + repo_owner = os.getenv("REPO_OWNER", "ruxailab") # Default to ruxailab if not set + repo_name = os.getenv("REPO_NAME", "disgitbot") + ref = os.getenv("WORKFLOW_REF", "main") + + gh_app = GitHubAppService() + + # Auto-discover installation ID if not provided + if not installation_id: + installation_id = gh_app.find_installation_id(repo_owner) + + if not installation_id: + print(f"Skipping pipeline trigger: could not find installation for {repo_owner}") + return False + + # Use the installation ID to get a token for the pipeline trigger + token = gh_app.get_installation_access_token(installation_id) + + if not token: + print(f"Skipping pipeline trigger: failed to get token for installation {installation_id}") + return False + + mt_client = get_mt_client() + 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 + + # Use the App token to trigger the workflow dispatch + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/discord_bot_pipeline.yml/dispatches" + headers = { + "Authorization": f"Bearer {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): + 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 + # Start cleanup thread _cleanup_thread = threading.Thread(target=cleanup_old_oauth_sessions, daemon=True) _cleanup_thread.start() @@ -210,55 +321,66 @@ def invite_bot(): @@ -360,43 +482,42 @@ def invite_bot():
-
Setup Required After Adding
+
Required Setup Activities
1
- Authorize Bot: Click the button above to add the bot to your server. + Authorize: Click the button above to add the bot.
2
- Configuration: Visit the setup dashboard:
- {setup_url} + Configure: Automatic redirect after authorization.
3
- Install GitHub App: Select which repositories you want to track. + Track: Install the App on your repositories.
4
- Link Accounts: Users can run /link in Discord. + Link: Users run /link in your Discord server.
-
📊 Real-time Stats
+
📊 Stats
🤖 Auto Roles
-
📈 Analytics Charts
-
🔊 Voice Updates
+
📈 Analytics
+
🔊 Updates
@@ -573,77 +694,38 @@ 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 - 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}", - "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): - 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) + # Trigger initial sync and Discord notification + sync_triggered = trigger_initial_sync(guild_id, github_org, int(installation_id)) + notify_setup_complete(guild_id, github_org) success_page = """ - Setup Completed!d! + Setup Completed! + + +
+ +

Setup Requested!

+

You don't have permission to install apps on this organization, so a request has been sent.

+ +
+
+ 1. + The organization owner will receive a notification to approve the installation of DisgitBot. +
+
+ 2. + Once approved, you can return to Discord and run the /setup command again to complete the connection for {{ guild_name }}. +
+
+ + +
+ + + """ + return render_template_string(pending_page, guild_name=guild_name) + + return "Missing installation_id. Please ensure you have permission to install apps onto your organization.", 400 try: payload = state_serializer.loads(state, max_age=60 * 60 * 24 * 7) # 7 days for org approval From 55ad0b6f36e82c1d7b9476d4e676ec84918b0dc4 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 00:26:06 +0700 Subject: [PATCH 60/64] fix(/sync): use REPO_OWNER installation token, improve UX, add /help command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: trigger_sync() now always fetches REPO_OWNER's GitHub App installation token for workflow dispatch — fixes 403 when the calling user's org differs from the pipeline repo owner - feat: /sync responses now use Discord embeds with colour coding • yellow — sync on cooldown (12 h), notes pipeline may have failed • green — workflow dispatch accepted • red — specific error per HTTP status (403 permission, 404 not found, 422 branch/file missing, timeout, unknown) - fix: sync status stored as 'dispatched' (was 'success') — bot only knows the API call succeeded, not whether the pipeline run itself passed - refactor: extract GitHubAppService.list_installations() so find_installation_id() delegates to it instead of duplicating the HTTP call - feat: add /help command with three embeds (Getting Started, Good to Know, Admin Commands — last shown only to admins) - fix: /add_repo and /remove_repo now validate the repo owner matches the server's configured GitHub org before proceeding - chore: disable /add_repo, /remove_repo, /list_repos registrations (webhook handler is inactive); left commented-out for future re-enable - docs: add REPO_OWNER / REPO_NAME / WORKFLOW_REF to .env.example (no comments — strict line-by-line validator requires exact match) - feat: env_validator.py — add REPO_OWNER (required=False), REPO_NAME and WORKFLOW_REF (optional, warn if empty, document defaults) - docs(MAINTAINER.md): add 'Setting Up /sync' section with step-by-step instructions (enable Actions R&W permission, set env vars, re-accept install) --- MAINTAINER.md | 56 +- discord_bot/config/.env.example | 3 + discord_bot/deployment/deploy.sh | 7 +- discord_bot/src/bot/auth.py | 614 +++++++++++++----- .../src/bot/commands/admin_commands.py | 111 +++- .../src/bot/commands/notification_commands.py | 50 +- discord_bot/src/bot/commands/user_commands.py | 109 ++++ .../src/services/github_app_service.py | 20 +- discord_bot/src/utils/env_validator.py | 14 + 9 files changed, 807 insertions(+), 177 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index de25928..ba45c10 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -2,6 +2,31 @@ This document explains how to manage the environment variables and how to re-enable features that are currently disabled (commented out) on the `feature/saas-ready` branch. +## Multi-Tenant Architecture + +### How GitHub Org ↔ Discord Server Works + +- **One GitHub org can be connected to multiple Discord servers.** +- Each Discord server stores its own config in `discord_servers/{guild_id}` with a `github_org` field. +- Org-scoped data (repo stats, PR config, monitoring) is stored under `organizations/{github_org}/...` and shared across all Discord servers connected to the same org. +- The GitHub App only needs to be **installed once per org** on GitHub. + +### Setup Flow + +| Scenario | Steps | Approval needed? | +|---|---|---| +| **Owner/Admin runs `/setup`** | `/setup` → click link → Install on GitHub → done | No (owner installs directly) | +| **Member runs `/setup`** | `/setup` → click link → "Request" on GitHub → owner approves from GitHub notification → owner runs `/setup` in Discord | Yes (first time only) | +| **Second Discord server, same org** | Anyone runs `/setup` → click link → app already installed → done | No (already installed) | + +### Key Points + +- Only the **first installation** per GitHub org requires the org owner to approve (if initiated by a non-owner member). +- Once a GitHub App is installed on an org, **any Discord server** can connect to it via `/setup` without needing another approval. +- `/add_repo` and `/remove_repo` are **scoped to the configured org** — you can only monitor repos within your connected GitHub organization. + +--- + ## Environment Variables ### Core Variables (Required for Launch) @@ -24,7 +49,36 @@ These are already in your `.env.example`: ### Feature-Specific Variables (Optional/Disabled) - `GITHUB_WEBHOOK_SECRET`: Required ONLY for PR automation. Used to verify that webhooks are actually coming from GitHub. - `GITHUB_TOKEN`: Original personal access token (largely replaced by GitHub App identity). -- `REPO_OWNER` / `REPO_NAME`: Used for triggering the initial sync pipeline. Defaults to `ruxailab/disgitbot`. +- `REPO_OWNER`: The GitHub account/org that **owns the `disgitbot` fork** where the pipeline workflow lives. Defaults to `ruxailab`. Must be set if you are running the bot from a fork. +- `REPO_NAME`: The repository name hosting the pipeline. Defaults to `disgitbot`. +- `WORKFLOW_REF`: The branch/tag to dispatch the workflow on. Defaults to `main`. Set this if your active branch is not `main` (e.g. `feature/saas-ready` during testing). + +--- + +## Setting Up `/sync` (Manual Pipeline Trigger) + +The `/sync` command lets Discord admins manually trigger the GitHub Actions data pipeline. It uses the GitHub App's installation token to dispatch a workflow on `REPO_OWNER/REPO_NAME`. + +### Required Steps + +**1. Set the correct env vars in `.env`:** +``` +REPO_OWNER= +REPO_NAME=disgitbot +WORKFLOW_REF=main # or your branch name during testing +``` + +**2. Enable Actions permission on the GitHub App:** +1. Go to `github.com/organizations/{your-org}/settings/apps/{your-app-slug}` +2. Click **Permissions & events** → **Repository permissions** +3. Find **Actions** (first item — "Workflows, workflow runs and artifacts") +4. Change it from `No access` → **Read & write** +5. Save changes + +**3. Accept the updated permissions:** +After saving, GitHub will notify all existing installations to accept the new permission. Go to `github.com/settings/installations` (or org equivalent) and approve the updated permissions for the installation on `REPO_OWNER`. + +> **Note:** `REPO_OWNER` must be the account where the GitHub App is **installed** (not just where it was created). If you forked the repo to a different org/account, install the App there first. --- diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index f384940..b404df1 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -7,3 +7,6 @@ GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY_B64= GITHUB_APP_SLUG= SECRET_KEY= +REPO_OWNER= +REPO_NAME= +WORKFLOW_REF= diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 2e3c783..2063f49 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -352,7 +352,7 @@ create_new_env_file() { # SECRET_KEY (auto-generate if left blank) echo -e "${BLUE}SECRET_KEY is used to sign session cookies (required for security).${NC}" - read -rp "SECRET_KEY (leave blank to auto-generate): " secret_key + read -p "SECRET_KEY (leave blank to auto-generate): " secret_key if [ -z "$secret_key" ]; then secret_key=$(python3 -c "import secrets; print(secrets.token_hex(32))") print_success "Auto-generated SECRET_KEY" @@ -405,14 +405,14 @@ edit_env_file() { read -p "GitHub App Slug [$GITHUB_APP_SLUG]: " new_github_app_slug github_app_slug=${new_github_app_slug:-$GITHUB_APP_SLUG} - read -rp "SECRET_KEY [$SECRET_KEY]: " new_secret_key + read -p "SECRET_KEY [$SECRET_KEY]: " new_secret_key secret_key=${new_secret_key:-$SECRET_KEY} # Auto-generate if still empty (e.g. key was missing in old .env and user pressed Enter) if [ -z "$secret_key" ]; then echo -e "${BLUE}SECRET_KEY is empty. Auto-generating a secure key...${NC}" secret_key=$(python3 -c "import secrets; print(secrets.token_hex(32))") - print_success "Auto-generated SECRET_KEY" + print_success "Generated: $secret_key" fi # Update .env file @@ -734,7 +734,6 @@ main() { # Copy pr_review directory into build context for PR automation print_step "Copying pr_review directory into build context..." if [ -d "$(dirname "$ROOT_DIR")/pr_review" ]; then - rm -rf "$ROOT_DIR/pr_review" cp -r "$(dirname "$ROOT_DIR")/pr_review" "$ROOT_DIR/pr_review" print_success "pr_review directory copied successfully" else diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 11fb2bc..ec6fd69 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -108,46 +108,75 @@ async def send_msg(): import asyncio asyncio.run_coroutine_threadsafe(send_msg(), bot.loop) -def trigger_initial_sync(guild_id: str, org_name: str, installation_id: Optional[int] = None) -> bool: - """Trigger the GitHub Actions pipeline using GitHub App identity.""" +def trigger_sync(guild_id: str, org_name: str, installation_id: Optional[int] = None, respect_cooldown: bool = True) -> dict: + """Trigger the GitHub Actions pipeline using GitHub App identity. + + The workflow lives in REPO_OWNER/REPO_NAME, so we always use the + installation token for REPO_OWNER (the bot developer's org), NOT + the user's org installation. The `installation_id` parameter is + kept for backward-compat but ignored for the dispatch call. + + Returns a dict with: + triggered (bool): Whether the pipeline was dispatched + error (str|None): Error message if failed + cooldown_remaining (int|None): Seconds remaining if blocked by cooldown + """ from src.services.github_app_service import GitHubAppService repo_owner = os.getenv("REPO_OWNER", "ruxailab") # Default to ruxailab if not set repo_name = os.getenv("REPO_NAME", "disgitbot") ref = os.getenv("WORKFLOW_REF", "main") + mt_client = get_mt_client() + existing_config = mt_client.get_server_config(guild_id) or {} + + # --- Cooldown check --- + # Only enforce cooldown after a SUCCESSFUL sync (12h). + # Failed syncs can be retried immediately. + if respect_cooldown: + last_sync_at = existing_config.get("last_sync_at") + last_sync_status = existing_config.get("last_sync_status") # "dispatched" or "failed" + if last_sync_at and last_sync_status == "dispatched": + try: + last_dt = datetime.fromisoformat(last_sync_at) + if last_dt.tzinfo is None: + last_dt = last_dt.replace(tzinfo=timezone.utc) + elapsed = datetime.now(timezone.utc) - last_dt + cooldown = timedelta(hours=12) + + if elapsed < cooldown: + remaining = int((cooldown - elapsed).total_seconds()) + print(f"Skipping pipeline trigger: cooldown active ({remaining}s remaining)") + return {"triggered": False, "error": None, "cooldown_remaining": remaining, "last_sync_status": "dispatched"} + except ValueError: + pass + gh_app = GitHubAppService() - # Auto-discover installation ID if not provided - if not installation_id: - installation_id = gh_app.find_installation_id(repo_owner) + # --- IMPORTANT: Always use the installation for REPO_OWNER --- + # The workflow dispatch targets REPO_OWNER/REPO_NAME (e.g. ruxailab/disgitbot). + # The user's org installation token does NOT have access to that repo. + # We must use the installation on REPO_OWNER itself. + pipeline_installation_id = gh_app.find_installation_id(repo_owner) - if not installation_id: - print(f"Skipping pipeline trigger: could not find installation for {repo_owner}") - return False + if not pipeline_installation_id: + error_msg = ( + f"The GitHub App is not installed on '{repo_owner}' (the organization that hosts the pipeline). " + f"The bot maintainer needs to install the GitHub App on '{repo_owner}' with Actions (read & write) permission." + ) + print(f"Skipping pipeline trigger: {error_msg}") + _save_sync_metadata(mt_client, guild_id, existing_config, "failed", error_msg) + return {"triggered": False, "error": error_msg, "cooldown_remaining": None} - # Use the installation ID to get a token for the pipeline trigger - token = gh_app.get_installation_access_token(installation_id) + token = gh_app.get_installation_access_token(pipeline_installation_id) if not token: - print(f"Skipping pipeline trigger: failed to get token for installation {installation_id}") - return False + error_msg = f"Failed to get access token for the pipeline installation on '{repo_owner}'" + print(f"Skipping pipeline trigger: {error_msg}") + _save_sync_metadata(mt_client, guild_id, existing_config, "failed", error_msg) + return {"triggered": False, "error": error_msg, "cooldown_remaining": None} - mt_client = get_mt_client() - 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 last_dt.tzinfo is None: - last_dt = last_dt.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) - last_dt < timedelta(minutes=10): - print("Skipping pipeline trigger: recent sync already triggered") - return False - except ValueError: - pass - - # Use the App token to trigger the workflow dispatch + # Dispatch the workflow on REPO_OWNER/REPO_NAME url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/discord_bot_pipeline.yml/dispatches" headers = { "Authorization": f"Bearer {token}", @@ -163,20 +192,210 @@ def trigger_initial_sync(guild_id: str, org_name: str, installation_id: Optional try: resp = requests.post(url, headers=headers, json=payload, timeout=20) if resp.status_code in (201, 204): - mt_client.set_server_config(guild_id, { - **existing_config, - "initial_sync_triggered_at": datetime.now(timezone.utc).isoformat() - }) - return True - print(f"Failed to trigger pipeline: {resp.status_code} {resp.text[:200]}") + _save_sync_metadata(mt_client, guild_id, existing_config, "dispatched", None) + return {"triggered": True, "error": None, "cooldown_remaining": None} + + # --- Map common HTTP errors to human-readable messages --- + status = resp.status_code + if status == 403: + error_msg = ( + "The GitHub App does not have permission to trigger workflows. " + f"Please ensure the App is installed on '{repo_owner}' with **Actions (read & write)** permission enabled." + ) + elif status == 404: + error_msg = ( + f"Pipeline workflow not found at '{repo_owner}/{repo_name}'. " + "The workflow file may have been removed or renamed." + ) + elif status == 422: + error_msg = ( + f"The workflow ref '{ref}' is invalid or the workflow is disabled. " + "Check that the branch/tag exists and the workflow is enabled." + ) + else: + error_msg = f"GitHub API returned HTTP {status}. Please try again later." + + print(f"Failed to trigger pipeline: HTTP {status} — {resp.text[:300]}") + _save_sync_metadata(mt_client, guild_id, existing_config, "failed", error_msg) + return {"triggered": False, "error": error_msg, "cooldown_remaining": None} + except requests.exceptions.Timeout: + error_msg = "The request to GitHub timed out. Please try again in a moment." + print(f"Error triggering pipeline: timeout") + _save_sync_metadata(mt_client, guild_id, existing_config, "failed", error_msg) + return {"triggered": False, "error": error_msg, "cooldown_remaining": None} except Exception as exc: + error_msg = "An unexpected error occurred while contacting GitHub. Please try again later." print(f"Error triggering pipeline: {exc}") - return False + _save_sync_metadata(mt_client, guild_id, existing_config, "failed", error_msg) + return {"triggered": False, "error": error_msg, "cooldown_remaining": None} + + +def _save_sync_metadata(mt_client, guild_id: str, existing_config: dict, status: str, error: Optional[str]): + """Save sync attempt metadata to server config.""" + update = { + **existing_config, + "last_sync_at": datetime.now(timezone.utc).isoformat(), + "last_sync_status": status, + } + if error: + update["last_sync_error"] = error + elif "last_sync_error" in update: + del update["last_sync_error"] + mt_client.set_server_config(guild_id, update) + + +def trigger_initial_sync(guild_id: str, org_name: str, installation_id: Optional[int] = None) -> bool: + """Convenience wrapper for setup flows — skips cooldown on first setup.""" + result = trigger_sync(guild_id, org_name, installation_id=installation_id, respect_cooldown=False) + return result["triggered"] # Start cleanup thread _cleanup_thread = threading.Thread(target=cleanup_old_oauth_sessions, daemon=True) _cleanup_thread.start() +def render_status_page(title, subtitle, icon_type="info", instructions=None, button_text=None, button_url=None, footer="You can safely close this window."): + """Render a consistent status/error page matching /invite and /setup design.""" + from flask import render_template_string + + # Icon colors per type + icon_colors = { + "success": "#43b581", + "error": "#f04747", + "warning": "#faa61a", + "info": "#7289da", + } + icon_color = icon_colors.get(icon_type, "#7289da") + + # All icons use a simple circle + inner symbol, matching the elegant style + icons = { + "success": f'', + "error": f'', + "warning": f'', + "info": f'', + } + icon_svg = icons.get(icon_type, icons["info"]) + + template = """ + + + + {{ title }} — DisgitBot + + + + + + + + +
+
{{ icon_svg|safe }}

{{ title }}

+

{{ subtitle|safe }}

+ + {% if instructions %} +
+
What to do
+ {% for instruction in instructions %} +
+
{{ loop.index }}
+
{{ instruction|safe }}
+
+ {% endfor %} + {% endif %} + + {% if button_text and button_url %} + + {% endif %} + + +
+ + + """ + return render_template_string( + template, + title=title, + subtitle=subtitle, + icon_svg=icon_svg, + instructions=instructions, + button_text=button_text, + button_url=button_url, + footer=footer + ) + def create_oauth_app(): """ Create and configure the Flask OAuth application. @@ -587,7 +806,13 @@ def github_callback(): discord_user_id = session.get('discord_user_id') if not discord_user_id: - return "Authentication failed: No Discord user session", 400 + return render_status_page( + title="Session Not Found", + subtitle="We couldn't link your account because the Discord session was missing.", + icon_type="error", + button_text="Try /link again", + button_url="https://discord.com/app" + ), 400 if not github.authorized: print("GitHub OAuth not authorized") @@ -597,7 +822,11 @@ def github_callback(): 'error': 'GitHub authorization failed' } _notify_link_event(discord_user_id) - return "GitHub authorization failed", 400 + return render_status_page( + title="Authorization Failed", + subtitle="GitHub authorization was denied. Please try the /link command again and approve the request.", + icon_type="error" + ), 400 resp = github.get("/user") if not resp.ok: @@ -608,7 +837,11 @@ def github_callback(): 'error': 'Failed to fetch GitHub user info' } _notify_link_event(discord_user_id) - return "Failed to fetch GitHub user information", 400 + return render_status_page( + title="Profile Fetch Failed", + subtitle="We couldn't retrieve your GitHub user information. Please try again later.", + icon_type="error" + ), 400 github_user = resp.json() github_username = github_user.get("login") @@ -621,7 +854,11 @@ def github_callback(): 'error': 'No GitHub username found' } _notify_link_event(discord_user_id) - return "Failed to get GitHub username", 400 + return render_status_page( + title="Username Not Found", + subtitle="We couldn't find a username for your GitHub account.", + icon_type="error" + ), 400 with oauth_sessions_lock: oauth_sessions[discord_user_id] = { @@ -634,22 +871,15 @@ def github_callback(): print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") - return f""" - - Authentication Successful - -

Authentication Successful!

-

Your Discord account has been linked to GitHub user: {github_username}

-

You can now close this tab and return to Discord.

- - - - """ + return render_status_page( + title="Authentication Successful!", + subtitle=f"Your Discord account has been linked to GitHub user: {github_username}.", + icon_type="success", + instructions=[ + "Return to Discord to see your linked status.", + "You can now use commands like /getstats with your own data." + ] + ) except Exception as e: print(f"Error in OAuth callback: {e}") @@ -657,21 +887,36 @@ def github_callback(): @app.route("/github/app/install") def github_app_install(): - """Redirect server owners to install the DisgitBot GitHub App.""" + """Redirect to GitHub to install the DisgitBot GitHub App. + + GitHub handles all permission checking natively: + - Org owners can install directly + - Non-owners see a 'Request' button → owner gets notified to approve + - Already-installed orgs show a 'Configure' option + """ 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 + return render_status_page( + title="Missing Server Information", + subtitle="We couldn't determine which Discord server you're trying to set up.", + icon_type="error", + button_text="Try /setup again", + button_url="https://discord.com/app" + ), 400 app_slug = os.getenv("GITHUB_APP_SLUG") if not app_slug: - return "Server configuration error: missing GITHUB_APP_SLUG", 500 + return render_status_page( + title="Configuration Error", + subtitle="The bot's GITHUB_APP_SLUG is not configured. Please contact the bot owner.", + icon_type="error" + ), 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) @@ -687,109 +932,136 @@ def github_app_setup(): setup_action = request.args.get('setup_action') state = request.args.get('state', '') + # --- CASE 1: No state parameter --- + # This happens when an org owner approves a request from GitHub directly. + # GitHub redirects the owner to the Setup URL WITHOUT state, because state + # was generated in the non-owner's session. if not state: - return "Missing state parameter. Please restart setup from Discord.", 400 - + if installation_id: + # Owner approved the installation from GitHub. + # Tell them to run /setup in Discord to complete the link. + gh_app = GitHubAppService() + installation = gh_app.get_installation(int(installation_id)) + github_org = '' + if installation: + account = installation.get('account') or {} + github_org = account.get('login', '') + + return render_status_page( + title="Installation Approved!", + subtitle=f"DisgitBot has been installed on {github_org}." if github_org else "DisgitBot has been installed successfully.", + icon_type="success", + instructions=[ + "Go back to your Discord server.", + "Run /setup to link this GitHub installation to your server.", + ], + button_text="Open Discord", + button_url="https://discord.com/app" + ) + else: + # No state AND no installation_id + return render_status_page( + title="Setup Session Missing", + subtitle="This link was opened directly without a valid session.", + icon_type="error", + instructions=[ + "Go back to your Discord server.", + "Run the /setup command.", + "Click the new link provided by the bot.", + ], + button_text="Open Discord", + button_url="https://discord.com/app" + ), 400 + + # --- CASE 2: State exists but no installation_id --- if not installation_id: if setup_action == 'request': - # Handle installation request from non-owner + # Non-owner clicked "Request" — installation sent to org owner for approval try: payload = state_serializer.loads(state, max_age=60 * 60 * 24 * 7) + guild_id = str(payload.get('guild_id', '')) guild_name = payload.get('guild_name', 'your server') - except: - return "Installation requested, but session expired. Please restart setup from Discord.", 400 - - pending_page = """ - - - - Setup Requested - - - - - - - - -
- -

Setup Requested!

-

You don't have permission to install apps on this organization, so a request has been sent.

- -
-
- 1. - The organization owner will receive a notification to approve the installation of DisgitBot. -
-
- 2. - Once approved, you can return to Discord and run the /setup command again to complete the connection for {{ guild_name }}. -
-
- - -
- - - """ - return render_template_string(pending_page, guild_name=guild_name) + except Exception: + return render_status_page( + title="Session Expired", + subtitle="Your setup session has expired.", + icon_type="error", + instructions=[ + "Go back to your Discord server.", + "Run /setup again to get a fresh link.", + ], + button_text="Open Discord", + button_url="https://discord.com/app" + ), 400 + + discord_url = f"https://discord.com/channels/{guild_id}" if guild_id else "https://discord.com/app" + return render_status_page( + title="Request Sent", + subtitle="A request to install DisgitBot has been sent to the organization owner.", + icon_type="success", + instructions=[ + "The organization owner will receive a notification on GitHub to approve the app.", + "After approving, the owner (or an admin) should run /setup in Discord to complete the connection.", + ], + button_text="Open Discord", + button_url=discord_url + ) - return "Missing installation_id. Please ensure you have permission to install apps onto your organization.", 400 + return render_status_page( + title="Installation Cancelled", + subtitle="The installation was not completed. This can happen if the process was cancelled on GitHub.", + icon_type="error", + instructions=[ + "Go back to your Discord server.", + "Run /setup and try installing again.", + "If you're not an org owner, click Request on the GitHub page.", + ], + button_text="Open Discord", + button_url="https://discord.com/app" + ), 400 + + # --- CASE 3: Both state and installation_id present (happy path) --- try: payload = state_serializer.loads(state, max_age=60 * 60 * 24 * 7) # 7 days for org approval except SignatureExpired: - return "Setup link expired. Please restart setup from Discord.", 400 + return render_status_page( + title="Setup Link Expired", + subtitle="The setup link you used is no longer valid (expired after 7 days).", + icon_type="error", + button_text="Get New Link", + button_url="https://discord.com/app" + ), 400 except BadSignature: - return "Invalid setup state. Please restart setup from Discord.", 400 + return render_status_page( + title="Invalid Setup State", + subtitle="The session information is invalid or has been tampered with.", + icon_type="error", + button_text="Restart Setup", + button_url="https://discord.com/app" + ), 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 + return render_status_page( + title="Invalid Setup State", + subtitle="The setup session is missing the Discord server ID.", + icon_type="error", + button_text="Restart Setup", + button_url="https://discord.com/app" + ), 400 gh_app = GitHubAppService() installation = gh_app.get_installation(int(installation_id)) if not installation: - return "Failed to fetch installation details from GitHub.", 500 + return render_status_page( + title="Installation Not Found", + subtitle="We couldn't verify the installation with GitHub. It might have been deleted or the GitHub API is temporarily unavailable.", + icon_type="error", + button_text="Try Again", + button_url=f"https://discord.com/channels/{guild_id}" + ), 500 account = installation.get('account') or {} github_account = account.get('login') @@ -807,12 +1079,16 @@ def github_app_setup(): 'github_account': github_account, 'github_account_type': github_account_type, 'setup_source': 'github_app', - 'created_at': datetime.now().isoformat(), + 'created_at': datetime.now(timezone.utc).isoformat(), 'setup_completed': True }) if not success: - return "Error: Failed to save configuration", 500 + return render_status_page( + title="Storage Error", + subtitle="We couldn't save your server configuration to our database. Please try again in a few moments.", + icon_type="error" + ), 500 @@ -880,7 +1156,7 @@ def github_app_setup(): .success-icon { color: #43b581; - width: 28px; height: 28px; + width: 24px; height: 24px; animation: popIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; } @@ -891,13 +1167,13 @@ def github_app_setup(): h1 { color: #ffffff; margin: 0; - font-size: 26px; font-weight: 800; - letter-spacing: -0.5px; + font-size: 19px; font-weight: 800; + letter-spacing: -0.4px; } .subtitle { color: #b9bbbe; margin: 0; - font-size: 15px; font-weight: 400; + font-size: 13px; font-weight: 400; } .highlight { color: #fff; font-weight: 600; } @@ -905,12 +1181,12 @@ def github_app_setup(): .divider { height: 1px; background: linear-gradient(90deg, rgba(255,255,255,0.0), rgba(255,255,255,0.1), rgba(255,255,255,0.0)); - margin: 30px 0; + margin: 20px 0; } .section-title { - margin: 0 0 15px 0; - font-size: 12px; text-transform: uppercase; letter-spacing: 1px; + margin: 0 0 12px 0; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; color: #949BA4; } @@ -1015,7 +1291,13 @@ def setup(): guild_name = request.args.get('guild_name', 'your server') if not guild_id: - return "Error: No Discord server information received", 400 + return render_status_page( + title="Missing Server Information", + subtitle="We couldn't determine which Discord server you're trying to set up.", + icon_type="error", + button_text="Try /setup again", + button_url="https://discord.com/app" + ), 400 github_app_install_url = f"{base_url}/github/app/install?{urlencode({'guild_id': guild_id, 'guild_name': guild_name})}" @@ -1197,11 +1479,23 @@ def complete_setup(): 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 + return render_status_page( + title="Missing Information", + subtitle="We couldn't complete the setup because some required information is missing.", + icon_type="error", + button_text="Try Again", + button_url=f"https://discord.com/channels/{guild_id}" if guild_id else "https://discord.com/app" + ), 400 # Validate GitHub organization name (basic validation) if not github_org.replace('-', '').replace('_', '').isalnum(): - return "Error: Invalid GitHub organization name", 400 + return render_status_page( + title="Invalid Organization Name", + subtitle="The GitHub organization name contains invalid characters.", + icon_type="error", + button_text="Try Again", + button_url=f"https://discord.com/channels/{guild_id}" + ), 400 try: # Store server configuration @@ -1209,12 +1503,16 @@ def complete_setup(): success = mt_client.set_server_config(guild_id, { 'github_org': github_org, 'setup_source': setup_source, - 'created_at': datetime.now().isoformat(), + 'created_at': datetime.now(timezone.utc).isoformat(), 'setup_completed': True }) if not success: - return "Error: Failed to save configuration", 500 + return render_status_page( + title="Storage Error", + subtitle="We couldn't save your server configuration to our database. Please try again in a few moments.", + icon_type="error" + ), 500 # Trigger initial sync and Discord notification # Auto-discovery will find the installation ID for the REPO_OWNER @@ -1391,7 +1689,11 @@ def complete_setup(): except Exception as e: print(f"Error in complete_setup: {e}") - return f"Error: Setup failed - {str(e)}", 500 + return render_status_page( + title="Setup Failed", + subtitle="An unexpected error occurred during setup. Please try again.", + icon_type="error" + ), 500 return app diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 999cc35..fcaa7d2 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -19,18 +19,19 @@ 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._sync_command()) self.bot.tree.add_command(self._setup_voice_stats_command()) # PR automation commands disabled - keeping code for future re-enablement # self.bot.tree.add_command(self._add_reviewer_command()) # self.bot.tree.add_command(self._remove_reviewer_command()) - self.bot.tree.add_command(self._list_reviewers_command()) + # self.bot.tree.add_command(self._list_reviewers_command()) def _check_permissions_command(self): """Create the check_permissions command.""" @app_commands.command(name="check_permissions", description="Check if bot has required permissions") async def check_permissions(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) - + guild = interaction.guild assert guild is not None, "Command should only work in guilds" assert self.bot.user is not None, "Bot user should be available" @@ -118,7 +119,111 @@ async def setup(interaction: discord.Interaction): traceback.print_exc() return setup - + + def _sync_command(self): + """Create the sync command for manually triggering data sync.""" + @app_commands.command(name="sync", description="Manually trigger a GitHub data sync for this server") + async def sync(interaction: discord.Interaction): + """Triggers the data pipeline to refresh GitHub stats.""" + 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 trigger a sync.", + ephemeral=True + ) + return + + guild = interaction.guild + assert guild is not None, "Command should only work in guilds" + guild_id = str(guild.id) + + # Check if server is set up + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = await asyncio.to_thread(mt_client.get_server_config, guild_id) or {} + + if not server_config.get('setup_completed'): + await interaction.followup.send( + "This server hasn't been set up yet. Run `/setup` first to connect a GitHub organization.", + ephemeral=True + ) + return + + github_org = server_config.get('github_org') + if not github_org: + await interaction.followup.send( + "No GitHub organization found for this server. Run `/setup` to configure.", + ephemeral=True + ) + return + + installation_id = server_config.get('github_installation_id') + + # Trigger sync (with cooldown enforcement) + from src.bot.auth import trigger_sync + result = await asyncio.to_thread( + trigger_sync, guild_id, github_org, + installation_id=installation_id, respect_cooldown=True + ) + + if result["cooldown_remaining"] is not None: + remaining = result["cooldown_remaining"] + hours = remaining // 3600 + minutes = (remaining % 3600) // 60 + + if hours > 0: + time_str = f"{hours}h {minutes}m" + else: + time_str = f"{minutes}m" + + embed = discord.Embed( + title="⏳ Sync on Cooldown", + description=( + f"A sync was already dispatched recently.\n\n" + f"Next manual sync available in **{time_str}**.\n\n" + f"The daily automatic sync also runs at **midnight UTC**.\n\n" + f"_Note: if the pipeline run itself failed, wait for the cooldown or contact the bot maintainer._" + ), + color=0xfee75c # yellow + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + if result["triggered"]: + embed = discord.Embed( + title="✅ Sync Triggered", + description=( + f"Data pipeline is now running for **{github_org}**.\n\n" + f"Stats will be updated in approximately **5–10 minutes**.\n\n" + f"_Use `/getstats` after a few minutes to see fresh data._" + ), + color=0x43b581 # green + ) + await interaction.followup.send(embed=embed, ephemeral=True) + else: + error_msg = result.get("error", "Unknown error") + embed = discord.Embed( + title="❌ Sync Failed", + description=error_msg, + color=0xed4245 # red + ) + embed.set_footer(text="If this persists, contact the bot maintainer or check GitHub App settings.") + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + await interaction.followup.send( + f"Error triggering sync: {str(e)}", + ephemeral=True + ) + print(f"Error in sync command: {e}") + import traceback + traceback.print_exc() + + return sync + def _setup_voice_stats_command(self): """Create the setup_voice_stats command.""" @app_commands.command(name="setup_voice_stats", description="Sets up voice channels for repository stats display") diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index bbb409d..a359649 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -10,6 +10,7 @@ from typing import Literal import re from src.services.notification_service import WebhookManager +from shared.firestore import get_mt_client class NotificationCommands: """Handles notification management Discord commands.""" @@ -19,12 +20,14 @@ def __init__(self, bot): def register_commands(self): """Register all notification commands with the bot.""" - # CI/CD monitoring commands (still useful) - self.bot.tree.add_command(self._add_repo_command()) - self.bot.tree.add_command(self._remove_repo_command()) - self.bot.tree.add_command(self._list_repos_command()) + # CI/CD monitoring commands disabled - webhook handler inactive + # To re-enable: uncomment below and re-enable /github/webhook handler in auth.py + # self.bot.tree.add_command(self._add_repo_command()) + # self.bot.tree.add_command(self._remove_repo_command()) + # self.bot.tree.add_command(self._list_repos_command()) # PR automation commands disabled - keeping code for future re-enablement # self.bot.tree.add_command(self._webhook_status_command()) + pass # /set_webhook command removed - PR automation feature disabled # To re-enable, restore the _set_webhook_command method and register it above @@ -44,6 +47,26 @@ async def add_repo(interaction: discord.Interaction, repository: str): ) return + # Validate repo belongs to the configured GitHub org + repo_owner = repository.split('/')[0] + mt_client = get_mt_client() + github_org = await asyncio.to_thread( + mt_client.get_org_from_server, + str(interaction.guild_id) + ) + if not github_org: + await interaction.followup.send( + "This server hasn't been set up yet. Run `/setup` first to connect a GitHub organization." + ) + return + if repo_owner.lower() != github_org.lower(): + await interaction.followup.send( + f"You can only monitor repositories within your configured organization **{github_org}**.\n" + f"The repository `{repository}` belongs to `{repo_owner}`, not `{github_org}`.\n\n" + f"Use the format: `{github_org}/repo-name`" + ) + return + # Add repository to monitoring list success = await asyncio.to_thread( WebhookManager.add_monitored_repository, @@ -84,6 +107,25 @@ async def remove_repo(interaction: discord.Interaction, repository: str): ) return + # Validate repo belongs to the configured GitHub org + repo_owner = repository.split('/')[0] + mt_client = get_mt_client() + github_org = await asyncio.to_thread( + mt_client.get_org_from_server, + str(interaction.guild_id) + ) + if not github_org: + await interaction.followup.send( + "This server hasn't been set up yet. Run `/setup` first to connect a GitHub organization." + ) + return + if repo_owner.lower() != github_org.lower(): + await interaction.followup.send( + f"You can only manage repositories within your configured organization **{github_org}**.\n" + f"The repository `{repository}` belongs to `{repo_owner}`, not `{github_org}`." + ) + return + # Remove repository from monitoring list success = await asyncio.to_thread( WebhookManager.remove_monitored_repository, diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 9cbacfc..9481aae 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -50,10 +50,119 @@ async def _safe_followup(self, interaction, message, embed=False): def register_commands(self): """Register all user commands with the bot.""" + self.bot.tree.add_command(self._help_command()) self.bot.tree.add_command(self._link_command()) self.bot.tree.add_command(self._unlink_command()) self.bot.tree.add_command(self._getstats_command()) self.bot.tree.add_command(self._halloffame_command()) + + def _help_command(self): + """Create the help command.""" + @app_commands.command(name="help", description="How DisgitBot works and how to get started") + async def help_cmd(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + is_admin = interaction.user.guild_permissions.administrator + + # --- Embed 1: Getting Started --- + start_embed = discord.Embed( + title="DisgitBot — Getting Started", + description=( + "DisgitBot tracks GitHub contributions for your organization " + "and displays stats, leaderboards, and auto-assigns roles in Discord." + ), + color=discord.Color.blurple() + ) + start_embed.add_field( + name="1️⃣ Setup (admin, one-time)", + value=( + "`/setup` → click link → install GitHub App on your org\n" + "Choose **All repositories** for automatic tracking of new repos." + ), + inline=False + ) + start_embed.add_field( + name="2️⃣ Link your account", + value="`/link` → authorize with GitHub → your stats are now tracked", + inline=False + ) + start_embed.add_field( + name="3️⃣ View stats", + value=( + "`/getstats` — your personal contribution stats\n" + "`/halloffame` — top 3 contributors leaderboard" + ), + inline=False + ) + + # --- Embed 2: Good to Know --- + faq_embed = discord.Embed( + title="Good to Know", + color=discord.Color.greyple() + ) + faq_embed.add_field( + name="📊 When does data update?", + value=( + "Automatically every night (midnight UTC).\n" + "Admins can force refresh with `/sync`.\n" + "After first setup, wait ~5–10 minutes for initial data." + ), + inline=False + ) + faq_embed.add_field( + name="📦 New repos not showing up?", + value=( + "If the GitHub App was installed with **Selected repositories**, " + "new repos won't be tracked automatically.\n" + "→ Go to **GitHub → Settings → GitHub Apps → Configure** " + "and add the new repo, or switch to **All repositories**." + ), + inline=False + ) + faq_embed.add_field( + name="👤 My stats are empty?", + value=( + "Make sure you've run `/link` first.\n" + "If you just set up, data may not be synced yet — " + "try `/sync` (admin) or wait for the next automatic sync." + ), + inline=False + ) + + embeds = [start_embed, faq_embed] + + # --- Embed 3: Admin Commands (only shown to admins) --- + if is_admin: + admin_embed = discord.Embed( + title="Admin Commands", + color=discord.Color.orange() + ) + admin_embed.add_field( + name="Commands", + value=( + "`/setup` — connect or check GitHub org connection\n" + "`/sync` — manually trigger data refresh (12h cooldown)\n" + "`/configure roles` — auto-assign roles based on contributions\n" + "`/setup_voice_stats` — voice channel repo stats display\n" + "`/check_permissions` — verify bot has required permissions" + ), + inline=False + ) + admin_embed.add_field( + name="Setup flow for organizations", + value=( + "If a **non-owner** member runs `/setup`, GitHub sends " + "an install **request** to the org owner.\n" + "After the owner approves on GitHub, " + "someone must run `/setup` again in Discord to complete the link." + ), + inline=False + ) + embeds.append(admin_embed) + + await interaction.followup.send(embeds=embeds, ephemeral=True) + + return help_cmd def _link_command(self): """Create the link command.""" diff --git a/discord_bot/src/services/github_app_service.py b/discord_bot/src/services/github_app_service.py index 783024d..e4c16c8 100644 --- a/discord_bot/src/services/github_app_service.py +++ b/discord_bot/src/services/github_app_service.py @@ -89,20 +89,22 @@ def get_installation_access_token(self, installation_id: int) -> Optional[str]: def find_installation_id(self, account_name: str) -> Optional[int]: """Find installation ID for a specific account name (org or user).""" + for inst in self.list_installations(): + if inst.get('account', {}).get('login') == account_name: + return inst.get('id') + return None + + def list_installations(self) -> list: + """Return all current installations of this GitHub App.""" try: url = f"{self.api_url}/app/installations" params = {"per_page": 100} resp = requests.get(url, headers=self._app_headers(), params=params, timeout=30) if resp.status_code != 200: print(f"Failed to list installations: {resp.status_code} {resp.text[:200]}") - return None - - installations = resp.json() - for inst in installations: - if inst.get('account', {}).get('login') == account_name: - return inst.get('id') - return None + return [] + return resp.json() except Exception as e: - print(f"Error finding installation for {account_name}: {e}") - return None + print(f"Error listing installations: {e}") + return [] diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 8b8f935..40fb62f 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -66,6 +66,20 @@ 'SECRET_KEY': { 'required': True, 'description': 'Flask session signing secret key (generate with: python3 -c "import secrets; print(secrets.token_hex(32))")' + }, + 'REPO_OWNER': { + 'required': False, + 'description': 'GitHub account/org that owns the disgitbot repo and has the GitHub App installed with Actions (read & write). Required for /sync to work.' + }, + 'REPO_NAME': { + 'required': False, + 'description': 'Repository name hosting the pipeline workflow. Defaults to disgitbot.', + 'warning_if_empty': 'REPO_NAME is empty — defaulting to disgitbot. Set this if your repo has a different name.' + }, + 'WORKFLOW_REF': { + 'required': False, + 'description': 'Branch or tag to dispatch the pipeline workflow on. Defaults to main.', + 'warning_if_empty': 'WORKFLOW_REF is empty — defaulting to main. Set this if your active branch is not main (e.g. feature/saas-ready during testing).' } } From dd01464ce3013353520a6e85e1e4ae2a2594052c Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 00:52:28 +0700 Subject: [PATCH 61/64] fix coderabbit bug: log warning when sync metadata write fails - _save_sync_metadata now checks set_server_config return value and prints a warning if the Firestore write fails, instead of silently dropping cooldown metadata - env_validator: add warning_if_empty for REPO_OWNER and document the ruxailab default (matching the actual os.getenv default in auth.py) --- MAINTAINER.md | 13 +++++++++++++ discord_bot/src/bot/auth.py | 3 ++- discord_bot/src/utils/env_validator.py | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index ba45c10..a7e3425 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -25,6 +25,19 @@ This document explains how to manage the environment variables and how to re-ena - Once a GitHub App is installed on an org, **any Discord server** can connect to it via `/setup` without needing another approval. - `/add_repo` and `/remove_repo` are **scoped to the configured org** — you can only monitor repos within your connected GitHub organization. +### `/sync` — Per-Server Cooldown, Shared Pipeline + +- The **12-hour cooldown is per Discord server** (keyed on `guild_id`). Each server stores its own `last_sync_at` + `last_sync_status` in `discord_servers/{guild_id}/config`. +- Two Discord servers connected to the **same GitHub org** each have independent cooldowns. If both trigger `/sync`, the pipeline runs twice on the same org's data — wasteful but harmless. +- The pipeline itself writes to `organizations/{github_org}/...`, which is shared. Running it twice back-to-back on the same org is safe (idempotent write). +- `trigger_initial_sync()` always bypasses the cooldown (`respect_cooldown=False`) so the first sync after `/setup` always fires. + +### Voice Channel Stats — Per-Guild, Updated by Pipeline + +- Each Discord server gets its own `REPOSITORY STATS` voice-channel category. The pipeline iterates over **all guilds** the bot is in and updates each one. +- The channels reflect org-level metrics (stars, forks, contributors, PRs, issues, commits) fetched from `organizations/{github_org}/...`. +- **Duplicate category root cause:** `discord.utils.get()` only returns the first matching category. If `/setup_voice_stats` and the pipeline's `_update_channels_for_guild` both run near-simultaneously (e.g., first deploy + immediate pipeline trigger), both find no existing category and both create one, resulting in two `REPOSITORY STATS` categories. The fix: scan for *all* categories with that name, keep the first, delete the rest. `/setup_voice_stats` now also detects and cleans up duplicates automatically. + --- ## Environment Variables diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index ec6fd69..a0915b9 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -241,7 +241,8 @@ def _save_sync_metadata(mt_client, guild_id: str, existing_config: dict, status: update["last_sync_error"] = error elif "last_sync_error" in update: del update["last_sync_error"] - mt_client.set_server_config(guild_id, update) + if not mt_client.set_server_config(guild_id, update): + print(f"Warning: failed to persist sync metadata for guild {guild_id}") def trigger_initial_sync(guild_id: str, org_name: str, installation_id: Optional[int] = None) -> bool: diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 40fb62f..dd29822 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -69,7 +69,8 @@ }, 'REPO_OWNER': { 'required': False, - 'description': 'GitHub account/org that owns the disgitbot repo and has the GitHub App installed with Actions (read & write). Required for /sync to work.' + 'description': 'GitHub account/org that owns the disgitbot repo and has the GitHub App installed with Actions (read & write). Required for /sync to work. Defaults to ruxailab if not set.', + 'warning_if_empty': 'REPO_OWNER is empty — defaulting to ruxailab. Set this if your pipeline repo lives under a different org/user.' }, 'REPO_NAME': { 'required': False, From 8eb152b2b30ca8b9273c8b0a7f2a00a5722ae17c Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 01:08:56 +0700 Subject: [PATCH 62/64] fix: auto-clean duplicate REPOSITORY STATS voice categories Root cause: discord.utils.get() returns only the first matching category. If /setup_voice_stats and the pipeline's _update_channels_for_guild both run near-simultaneously (e.g. first deploy + immediate /sync), neither finds an existing category and both create one, leaving two duplicates. Fix: - guild_service._update_channels_for_guild: scan for ALL categories named 'REPOSITORY STATS', keep the first, delete channels + category for any extras before proceeding with the update - admin_commands.setup_voice_stats: same scan; if duplicates are found it cleans them up and reports the result instead of just saying 'already exists' --- .../src/bot/commands/admin_commands.py | 22 ++++++++++++++++--- discord_bot/src/services/guild_service.py | 21 ++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index fcaa7d2..a9cad97 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -234,9 +234,25 @@ async def setup_voice_stats(interaction: discord.Interaction): guild = interaction.guild assert guild is not None, "Command should only work in guilds" - existing_category = discord.utils.get(guild.categories, name="REPOSITORY STATS") - - if existing_category: + all_stats_categories = [c for c in guild.categories if c.name == "REPOSITORY STATS"] + if len(all_stats_categories) > 1: + # Clean up duplicates — keep the first, delete the rest + for dup in all_stats_categories[1:]: + for ch in dup.channels: + try: + await ch.delete() + except Exception: + pass + try: + await dup.delete() + except Exception: + pass + await interaction.followup.send( + "⚠️ Found duplicate stats categories — cleaned up. " + "One 'REPOSITORY STATS' category remains. " + "Stats are updated daily via automated workflow." + ) + elif all_stats_categories: await interaction.followup.send("Repository stats display already exists! Stats are updated daily via automated workflow.") else: await guild.create_category("REPOSITORY STATS") diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index 41a3f03..dadef3b 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -223,9 +223,26 @@ async def _update_channels_for_guild(self, guild: discord.Guild, metrics: Dict[s print(f"Updating channels in guild: {guild.name}") # Find or create stats category - stats_category = discord.utils.get(guild.categories, name="REPOSITORY STATS") - if not stats_category: + # Use a list scan instead of discord.utils.get so we can detect and + # clean up duplicate categories (can appear if setup and the pipeline + # both try to create the category at the same time). + all_stats_categories = [c for c in guild.categories if c.name == "REPOSITORY STATS"] + if not all_stats_categories: stats_category = await guild.create_category("REPOSITORY STATS") + else: + stats_category = all_stats_categories[0] + # Delete any extras, including all their channels + for dup in all_stats_categories[1:]: + for ch in dup.channels: + try: + await ch.delete() + except Exception: + pass + try: + await dup.delete() + print(f"Deleted duplicate REPOSITORY STATS category in {guild.name}") + except Exception as e: + print(f"Could not delete duplicate category in {guild.name}: {e}") # Channel names for all repository metrics channels_to_update = [ From d4613b2ea6d4daa90b678c1f9c1ce3e1dbe35d08 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 01:36:15 +0700 Subject: [PATCH 63/64] fix(env_validator): allow .env with fewer lines than .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional vars (REPO_OWNER, REPO_NAME, WORKFLOW_REF) may be absent when the maintainer does not need /sync. The strict exact-line-count check was blocking deploy.sh validation for any .env that omits those lines. Change: only error if .env has MORE lines than .env.example (unexpected extras). Fewer lines are allowed — FIELD_CONFIG already handles missing optional vars as warnings and missing required vars as errors. Field requirement validation gate updated from == to <= accordingly. --- discord_bot/src/utils/env_validator.py | 29 +++++++++++--------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index dd29822..05e51d8 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -212,23 +212,18 @@ def validate_env_strict(env_example_path: str, env_path: str) -> dict: result['errors'].append(f"Failed to read .env: {e}") return result - # 1. CHECK LINE COUNT MATCHES EXACTLY - if len(example_lines) != len(env_lines): + # 1. CHECK LINE COUNT + # Extra lines beyond .env.example are always an error. + # Fewer lines are allowed — optional vars at the end can be omitted; + # FIELD_CONFIG handles missing optional fields as warnings below. + if len(env_lines) > len(example_lines): + extra_count = len(env_lines) - len(example_lines) result['format_errors'].append( - f"Line count mismatch: expected {len(example_lines)} lines, found {len(env_lines)} lines" + f"Line count mismatch: expected at most {len(example_lines)} lines, found {len(env_lines)} lines" + ) + result['format_errors'].append( + f"Found {extra_count} extra line(s) at the end (lines {len(example_lines)+1}-{len(env_lines)})" ) - - # Show which lines are extra/missing - if len(env_lines) > len(example_lines): - extra_count = len(env_lines) - len(example_lines) - result['format_errors'].append( - f"Found {extra_count} extra line(s) at the end (lines {len(example_lines)+1}-{len(env_lines)})" - ) - else: - missing_count = len(example_lines) - len(env_lines) - result['format_errors'].append( - f"Missing {missing_count} line(s) at the end" - ) # 2. FOR EACH LINE: COMPARE VARIABLE NAMES (left of =) ONLY max_lines = min(len(example_lines), len(env_lines)) # Only compare existing lines @@ -285,8 +280,8 @@ def validate_env_strict(env_example_path: str, env_path: str) -> dict: if env_data.get('format_issues'): result['format_errors'].extend(env_data['format_issues']) - # Only validate field requirements if structure matches - if len(example_lines) == len(env_lines) and len(result['line_mismatches']) == 0: + # Only validate field requirements if structure matches (no extra lines, no key mismatches) + if len(env_lines) <= len(example_lines) and len(result['line_mismatches']) == 0: # Check all configured fields based on their requirements for field_name, field_config in FIELD_CONFIG.items(): is_required = field_config.get('required', True) From 9426cd9ad0577aced5eb73a3c880dbfe2adf9a05 Mon Sep 17 00:00:00 2001 From: tim48-robot Date: Sat, 21 Feb 2026 01:39:04 +0700 Subject: [PATCH 64/64] fix(deploy.sh): include REPO_OWNER/REPO_NAME/WORKFLOW_REF in .env edit/create flows Both create_new_env_file() and edit_env_file() previously wrote only 9 lines, causing validation to fail with 'expected 12 lines, found 9'. Both functions now prompt for the 3 optional /sync vars and write all 12 lines to match .env.example exactly. - create_new_env_file: shows a brief optional hint with defaults - edit_env_file: shows current value as default, uses env default as fallback (REPO_OWNER -> ruxailab, REPO_NAME -> disgitbot, etc.) --- discord_bot/deployment/deploy.sh | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 2063f49..5cc8a61 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -357,7 +357,14 @@ create_new_env_file() { secret_key=$(python3 -c "import secrets; print(secrets.token_hex(32))") print_success "Auto-generated SECRET_KEY" fi - + + # /sync optional vars + echo -e "\n${BLUE}Optional: /sync command (manually trigger the data pipeline).${NC}" + echo -e "${BLUE}Leave blank to use defaults (REPO_OWNER=ruxailab, REPO_NAME=disgitbot, WORKFLOW_REF=main).${NC}" + read -p "REPO_OWNER (GitHub org that owns the pipeline repo) [ruxailab]: " repo_owner + read -p "REPO_NAME (pipeline repo name) [disgitbot]: " repo_name + read -p "WORKFLOW_REF (branch/tag to dispatch on) [main]: " workflow_ref + # Create .env file cat > "$ENV_PATH" << EOF DISCORD_BOT_TOKEN=$discord_token @@ -369,6 +376,9 @@ GITHUB_APP_ID=$github_app_id GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 GITHUB_APP_SLUG=$github_app_slug SECRET_KEY=$secret_key +REPO_OWNER=$repo_owner +REPO_NAME=$repo_name +WORKFLOW_REF=$workflow_ref EOF print_success ".env file created successfully!" @@ -414,7 +424,16 @@ edit_env_file() { secret_key=$(python3 -c "import secrets; print(secrets.token_hex(32))") print_success "Generated: $secret_key" fi - + + # /sync optional vars + echo -e "\n${BLUE}Optional: /sync vars (press Enter to keep current or use default).${NC}" + read -p "REPO_OWNER [${REPO_OWNER:-ruxailab}]: " new_repo_owner + repo_owner=${new_repo_owner:-${REPO_OWNER:-}} + read -p "REPO_NAME [${REPO_NAME:-disgitbot}]: " new_repo_name + repo_name=${new_repo_name:-${REPO_NAME:-}} + read -p "WORKFLOW_REF [${WORKFLOW_REF:-main}]: " new_workflow_ref + workflow_ref=${new_workflow_ref:-${WORKFLOW_REF:-}} + # Update .env file cat > "$ENV_PATH" << EOF DISCORD_BOT_TOKEN=$discord_token @@ -426,6 +445,9 @@ GITHUB_APP_ID=$github_app_id GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 GITHUB_APP_SLUG=$github_app_slug SECRET_KEY=$secret_key +REPO_OWNER=$repo_owner +REPO_NAME=$repo_name +WORKFLOW_REF=$workflow_ref EOF print_success ".env file updated successfully!"