diff --git a/MAINTAINER.md b/MAINTAINER.md new file mode 100644 index 0000000..de25928 --- /dev/null +++ b/MAINTAINER.md @@ -0,0 +1,74 @@ +# Maintainer Guide + +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. + +## Environment Variables + +### Core Variables (Required for Launch) +These are already in your `.env.example`: +- `DISCORD_BOT_TOKEN`: The bot token from Discord Developer Portal. +- `DISCORD_BOT_CLIENT_ID`: The client ID of your Discord bot. +- `GITHUB_CLIENT_ID`: OAuth client ID from your GitHub App. +- `GITHUB_CLIENT_SECRET`: OAuth client secret from your GitHub App. +- `OAUTH_BASE_URL`: The public URL where the bot is hosted (e.g., `https://your-bot.cloudfunctions.net`). +- `GITHUB_APP_ID`: Your GitHub App ID. +- `GITHUB_APP_PRIVATE_KEY_B64`: Your GitHub App's private key, encoded in Base64. +- `GITHUB_APP_SLUG`: The URL-friendly name of your GitHub App. + +### Security Variables (Recommended for Production) +- `SECRET_KEY`: Used by Flask to sign session cookies. + - **Usage**: Encrypting the `discord_user_id` during the `/link` flow. + - **Manual Check**: If you change this key while a user is mid-authentication, their session will be invalidated, and they will see "Authentication failed: No Discord user session". + - **Generation**: `python3 -c "import secrets; print(secrets.token_hex(32))"` + +### 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`. + +--- + +## Re-enabling PR Automation + +PR automation is currently commented out to simplify the SaaS experience. To re-enable it: + +### 1. Uncomment Command Registration +In `discord_bot/src/bot/commands/admin_commands.py`: +```python +# In register_commands(): +self.bot.tree.add_command(self._add_reviewer_command()) +self.bot.tree.add_command(self._remove_reviewer_command()) +``` + +In `discord_bot/src/bot/commands/notification_commands.py`: +```python +# In register_commands(): +# self.bot.tree.add_command(self._webhook_status_command()) +``` + +### 2. Configure GitHub App Webhooks +1. Go to your GitHub App settings. +2. Enable **Webhooks**. +3. **Webhook URL**: `{OAUTH_BASE_URL}/github-webhook` +4. **Webhook Secret**: Set a random string and update `GITHUB_WEBHOOK_SECRET` in your `.env`. +5. **Permissions & Events**: + - Push: `read & write` (checks) + - Pull Requests: `read & write` + - Repository metadata: `read-only` + - Subscribe to: `Pull request`, `Push`, `Workflow run`. + +### 3. Performance & Responsiveness +- **Async I/O**: Use `await asyncio.to_thread` for all Firestore and synchronous network calls. +- **CPU-Bound Tasks**: Avoid long-running computations (like image generation) in the main thread. Wrap them in `asyncio.to_thread` to keep the bot responsive. +- **Shared Object Model**: Use the `shared.bot_instance` pattern for cross-thread communication between Flask and Discord. + +### 4. Async Architecture Pattern +Always use this pattern for blocking calls: + +```python +# Offload to thread to keep event loop free +result = await asyncio.to_thread(get_document, 'collection', 'doc_id', discord_server_id) + +# Offload CPU-bound calculations +buffer = await asyncio.to_thread(generate_complex_chart, data) +``` diff --git a/discord_bot/README.md b/discord_bot/README.md index 6ed7753..cafd0e7 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 3) +- `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` @@ -318,7 +296,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the - Click "New OAuth App" 3. **Fill in Application Details:** - **Application name:** `Your Bot Name` (anything you want) - - **Homepage URL:** `YOUR_CLOUD_RUN_URL` (from Step 4) + - **Homepage URL:** `YOUR_CLOUD_RUN_URL` (from Step 3) - **Authorization callback URL:** `YOUR_CLOUD_RUN_URL/login/github/authorized` **Example URLs:** If your Cloud Run URL is `https://discord-bot-abcd1234-uc.a.run.app`, then: @@ -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 ebf50d6..f384940 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -1,10 +1,9 @@ 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= +SECRET_KEY= diff --git a/discord_bot/deployment/Dockerfile b/discord_bot/deployment/Dockerfile index be0e20c..f727572 100644 --- a/discord_bot/deployment/Dockerfile +++ b/discord_bot/deployment/Dockerfile @@ -16,10 +16,17 @@ RUN apt-get update && \ # Copy requirements first to leverage Docker cache COPY requirements.txt . +# Copy only requirements files first to leverage Docker layer cache +COPY pr_review/requirements.txt ./pr_review/requirements.txt + # Upgrade pip to latest version to avoid upgrade notices -RUN pip install --upgrade pip +RUN pip install --no-cache-dir --upgrade pip + +# Install dependencies from both requirements files +RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt -r pr_review/requirements.txt -RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt +# Copy pr_review package (copied into build context by deploy script) +COPY pr_review ./pr_review # Create config directory and empty credentials file (will be overwritten by volume mount) RUN mkdir -p /app/config && echo "{}" > /app/config/credentials.json diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 96842f7..2e3c783 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 @@ -355,19 +349,26 @@ create_new_env_file() { 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 + + # 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 + if [ -z "$secret_key" ]; then + secret_key=$(python3 -c "import secrets; print(secrets.token_hex(32))") + print_success "Auto-generated SECRET_KEY" + fi # 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 GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 GITHUB_APP_SLUG=$github_app_slug +SECRET_KEY=$secret_key EOF print_success ".env file created successfully!" @@ -383,18 +384,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} @@ -409,19 +404,28 @@ 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 + 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" + fi # 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 GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 GITHUB_APP_SLUG=$github_app_slug +SECRET_KEY=$secret_key EOF print_success ".env file updated successfully!" @@ -727,6 +731,16 @@ 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 + 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 + 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 +753,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/requirements.txt b/discord_bot/requirements.txt index 903042f..7dc27e8 100644 --- a/discord_bot/requirements.txt +++ b/discord_bot/requirements.txt @@ -1,7 +1,7 @@ discord.py[voice]==2.0.0 python-dotenv==1.0.0 firebase-admin==6.7.0 -aiohttp==3.9.1 +aiohttp>=3.12.14 audioop-lts Flask==3.0.0 Flask-Dance==7.0.0 diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 97e5a14..4d6b86c 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,8 +1,13 @@ import os +from typing import Optional +from datetime import datetime, timedelta, timezone +from shared.firestore import get_mt_client 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 @@ -14,6 +19,164 @@ oauth_sessions = {} oauth_sessions_lock = threading.Lock() +# Event-driven link notification system +# Maps discord_user_id -> asyncio.Event (set when OAuth completes/fails) +_link_events = {} +_link_events_lock = threading.Lock() + +def register_link_event(discord_user_id: str, event) -> None: + """Register an asyncio.Event for a pending /link command.""" + with _link_events_lock: + _link_events[discord_user_id] = event + +def unregister_link_event(discord_user_id: str) -> None: + """Clean up link event after /link completes or times out.""" + with _link_events_lock: + _link_events.pop(discord_user_id, None) + +def _notify_link_event(discord_user_id: str) -> None: + """Wake up the waiting /link command from the Flask thread. + + Called after oauth_sessions is updated with the result. + Uses call_soon_threadsafe to safely set the asyncio.Event + from the Flask (non-asyncio) thread. + """ + with _link_events_lock: + event = _link_events.get(discord_user_id) + if event: + from . import shared + if shared.bot_instance and shared.bot_instance.bot: + shared.bot_instance.bot.loop.call_soon_threadsafe(event.set) + +# 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}") + +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 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 + 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(timezone.utc).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() + def create_oauth_app(): """ Create and configure the Flask OAuth application. @@ -53,47 +216,117 @@ 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" } }) - - @app.route("/debug/servers") - def debug_servers(): - """Debug endpoint to see registered servers""" + + @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 + + # 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("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") + 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: - 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 - }) - + 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(): + try: + from pr_review.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({ - "total_servers": len(servers), - "servers": servers - }) - + "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""" - 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 = ( @@ -104,96 +337,225 @@ def invite_bot(): f"scope=bot+applications.commands" ) - # Enhanced landing page with clear instructions + + + # 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.

+ + + + Add DisgitBot to Discord + + + + + + + + + +
+

Add DisgitBot

+

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

+ + + + + + Add to Discord + + +
+
Required Setup Activities
+ +
+
1
+
+ Authorize: Click the button above to add the bot.
-
- Automated role assignment +
+ +
+
2
+
+ Configure: Automatic redirect after authorization.
-
- Contribution analytics & charts +
+ +
+
3
+
+ Track: Install the App on your repositories.
-
- Auto-updating voice channels +
+ +
+
4
+
+ Link: Users run /link in your Discord server.
- -

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

- - - """ +
+ +
+
📊 Stats
+
🤖 Auto Roles
+
📈 Analytics
+
🔊 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): @@ -234,6 +596,7 @@ def github_callback(): 'status': 'failed', 'error': 'GitHub authorization failed' } + _notify_link_event(discord_user_id) return "GitHub authorization failed", 400 resp = github.get("/user") @@ -244,6 +607,7 @@ def github_callback(): 'status': 'failed', 'error': 'Failed to fetch GitHub user info' } + _notify_link_event(discord_user_id) return "Failed to fetch GitHub user information", 400 github_user = resp.json() @@ -256,6 +620,7 @@ def github_callback(): 'status': 'failed', 'error': 'No GitHub username found' } + _notify_link_event(discord_user_id) return "Failed to get GitHub username", 400 with oauth_sessions_lock: @@ -263,6 +628,7 @@ def github_callback(): 'status': 'completed', 'github_username': github_username } + _notify_link_event(discord_user_id) session.pop('discord_user_id', None) @@ -324,7 +690,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: @@ -363,103 +729,183 @@ 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 = """ - GitHub Connected! + Setup Completed! + + +
-

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
@@ -495,53 +941,149 @@ 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 + +
+ +
@@ -589,47 +1131,171 @@ def complete_setup(): if not success: return "Error: Failed to save configuration", 500 + # Trigger initial sync and Discord notification + # Auto-discovery will find the installation ID for the REPO_OWNER + trigger_initial_sync(guild_id, github_org) + notify_setup_complete(guild_id, github_org) success_page = """ - Setup Complete! + Setup Completed! + + +
-

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

@@ -652,33 +1318,3 @@ def get_github_username_for_user(discord_user_id): return f"{base_url}/auth/start/{discord_user_id}" -def wait_for_username(discord_user_id, max_wait_time=300): - """Wait for OAuth completion by polling the status""" - start_time = time.time() - - while time.time() - start_time < max_wait_time: - with oauth_sessions_lock: - session_data = oauth_sessions.get(discord_user_id) - - if session_data: - if session_data['status'] == 'completed': - github_username = session_data.get('github_username') - # Clean up - del oauth_sessions[discord_user_id] - return github_username - elif session_data['status'] == 'failed': - error = session_data.get('error', 'Unknown error') - print(f"OAuth failed for {discord_user_id}: {error}") - # Clean up - del oauth_sessions[discord_user_id] - return None - - time.sleep(2) # Poll every 2 seconds - - print(f"OAuth timeout for Discord user: {discord_user_id}") - # Clean up timeout session - with oauth_sessions_lock: - if discord_user_id in oauth_sessions: - del oauth_sessions[discord_user_id] - - return None diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index ac3a002..b1a1604 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -4,8 +4,11 @@ Clean, modular Discord bot initialization and setup. """ +import asyncio import os import sys +from datetime import datetime, timedelta, timezone + import discord from discord.ext import commands from dotenv import load_dotenv @@ -20,6 +23,10 @@ def __init__(self): self._setup_environment() self._create_bot() self._register_commands() + + # Store global reference for cross-thread communication + from . import shared + shared.bot_instance = self def _setup_environment(self): """Setup environment variables and logging.""" @@ -50,9 +57,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 @@ -62,12 +66,25 @@ async def on_ready(): async def on_guild_join(guild): """Called when bot joins a new server - provide setup guidance.""" try: - # Check if server is already configured + # Check if server is already configured (offload to thread to avoid blocking) from shared.firestore import get_mt_client mt_client = get_mt_client() - server_config = mt_client.get_server_config(str(guild.id)) + server_config = await asyncio.to_thread(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: + try: + last_dt = datetime.fromisoformat(last_reminder) + if last_dt.tzinfo is None: + last_dt = last_dt.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) - 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 +116,12 @@ async def on_guild_join(guild): *This message will only appear once during setup.*""" await system_channel.send(setup_message) + + # Mark reminder as sent + await asyncio.to_thread(mt_client.set_server_config, str(guild.id), { + **server_config, + 'setup_reminder_sent_at': datetime.now(timezone.utc).isoformat() + }) print(f"Sent setup guidance to server: {guild.name} (ID: {guild.id})") except Exception as e: @@ -106,54 +129,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)) - 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") - 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) - 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) diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 259308a..999cc35 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -4,6 +4,7 @@ Handles administrative Discord commands like permissions and setup. """ +import asyncio import discord from discord import app_commands from shared.firestore import get_document, set_document @@ -19,8 +20,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): @@ -70,7 +72,7 @@ async def setup(interaction: discord.Interaction): # 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 {} + server_config = await asyncio.to_thread(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( @@ -153,7 +155,7 @@ async def add_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration discord_server_id = str(interaction.guild.id) - reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) + reviewer_data = await asyncio.to_thread(get_document, 'pr_config', 'reviewers', discord_server_id) if not reviewer_data: reviewer_data = {'reviewers': [], 'manual_reviewers': [], 'top_contributor_reviewers': [], 'count': 0} @@ -175,7 +177,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, discord_server_id=discord_server_id) + success = await asyncio.to_thread(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)}") @@ -200,7 +202,7 @@ async def remove_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration discord_server_id = str(interaction.guild.id) - reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) + reviewer_data = await asyncio.to_thread(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 @@ -224,7 +226,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, discord_server_id=discord_server_id) + success = await asyncio.to_thread(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)}") @@ -253,8 +255,8 @@ async def list_reviewers(interaction: discord.Interaction): try: # Get reviewer data 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) + reviewer_data = await asyncio.to_thread(get_document, 'pr_config', 'reviewers', discord_server_id) + contributor_data = await asyncio.to_thread(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 8b668b9..b58077b 100644 --- a/discord_bot/src/bot/commands/analytics_commands.py +++ b/discord_bot/src/bot/commands/analytics_commands.py @@ -4,6 +4,7 @@ Handles analytics and visualization-related Discord commands. """ +import asyncio import discord from discord import app_commands from ...utils.analytics import create_top_contributors_chart, create_activity_comparison_chart, create_activity_trend_chart, create_time_series_chart @@ -24,19 +25,20 @@ def register_commands(self): def _show_top_contributors_command(self): """Create the show-top-contributors command.""" + @app_commands.guild_only() @app_commands.command(name="show-top-contributors", description="Show top contributors chart") async def show_top_contributors(interaction: discord.Interaction): await interaction.response.defer() try: discord_server_id = str(interaction.guild.id) - analytics_data = get_document('repo_stats', 'analytics', discord_server_id) + analytics_data = await asyncio.to_thread(get_document, 'repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) return - chart_buffer = create_top_contributors_chart(analytics_data, 'prs', "Top Contributors by PRs") + chart_buffer = await asyncio.to_thread(create_top_contributors_chart, analytics_data, 'prs', "Top Contributors by PRs") if not chart_buffer: await interaction.followup.send("No data available to generate chart.", ephemeral=True) @@ -53,19 +55,20 @@ async def show_top_contributors(interaction: discord.Interaction): def _show_activity_comparison_command(self): """Create the show-activity-comparison command.""" + @app_commands.guild_only() @app_commands.command(name="show-activity-comparison", description="Show activity comparison chart") async def show_activity_comparison(interaction: discord.Interaction): await interaction.response.defer() try: discord_server_id = str(interaction.guild.id) - analytics_data = get_document('repo_stats', 'analytics', discord_server_id) + analytics_data = await asyncio.to_thread(get_document, 'repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) return - chart_buffer = create_activity_comparison_chart(analytics_data, "Activity Comparison") + chart_buffer = await asyncio.to_thread(create_activity_comparison_chart, analytics_data, "Activity Comparison") if not chart_buffer: await interaction.followup.send("No data available to generate chart.", ephemeral=True) @@ -82,19 +85,20 @@ async def show_activity_comparison(interaction: discord.Interaction): def _show_activity_trends_command(self): """Create the show-activity-trends command.""" + @app_commands.guild_only() @app_commands.command(name="show-activity-trends", description="Show recent activity trends") async def show_activity_trends(interaction: discord.Interaction): await interaction.response.defer() try: discord_server_id = str(interaction.guild.id) - analytics_data = get_document('repo_stats', 'analytics', discord_server_id) + analytics_data = await asyncio.to_thread(get_document, 'repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) return - chart_buffer = create_activity_trend_chart(analytics_data, "Recent Activity Trends") + chart_buffer = await asyncio.to_thread(create_activity_trend_chart, analytics_data, "Recent Activity Trends") if not chart_buffer: await interaction.followup.send("No data available to generate chart.", ephemeral=True) @@ -111,6 +115,7 @@ async def show_activity_trends(interaction: discord.Interaction): def _show_time_series_command(self): """Create the show-time-series command.""" + @app_commands.guild_only() @app_commands.command(name="show-time-series", description="Show time series chart with customizable metrics and date range") @app_commands.describe( metrics="Comma-separated metrics to display (prs,issues,commits,total)", @@ -134,13 +139,14 @@ async def show_time_series(interaction: discord.Interaction, metrics: str = "prs return discord_server_id = str(interaction.guild.id) - analytics_data = get_document('repo_stats', 'analytics', discord_server_id) + analytics_data = await asyncio.to_thread(get_document, 'repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) return - chart_buffer = create_time_series_chart( + chart_buffer = await asyncio.to_thread( + create_time_series_chart, analytics_data, metrics=selected_metrics, days=days, diff --git a/discord_bot/src/bot/commands/config_commands.py b/discord_bot/src/bot/commands/config_commands.py index b5d1491..af3c17b 100644 --- a/discord_bot/src/bot/commands/config_commands.py +++ b/discord_bot/src/bot/commands/config_commands.py @@ -4,10 +4,14 @@ Server configuration commands for role mappings and setup checks. """ +import asyncio +import logging import discord from discord import app_commands from shared.firestore import get_mt_client +logger = logging.getLogger(__name__) + class ConfigCommands: """Handles configuration commands for server administrators.""" @@ -64,7 +68,7 @@ async def configure_roles( return mt_client = get_mt_client() - server_config = mt_client.get_server_config(str(guild.id)) or {} + server_config = await asyncio.to_thread(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 @@ -84,7 +88,7 @@ async def configure_roles( 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 asyncio.to_thread(mt_client.set_server_config, str(guild.id), server_config) await interaction.followup.send("Role rules reset to defaults.", ephemeral=True) return @@ -100,6 +104,27 @@ 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.me + if bot_member is None: + try: + bot_member = await guild.fetch_member(self.bot.user.id) + except Exception: + logger.warning(f"Could not fetch bot member in guild {guild.id}") + await interaction.followup.send( + "❌ Unable to verify role permissions. Please ensure I have the Manage Roles permission.", + ephemeral=True + ) + return + if 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, []) @@ -116,7 +141,7 @@ async def configure_roles( role_rules[metric_key] = rules server_config['role_rules'] = role_rules - mt_client.set_server_config(str(guild.id), server_config) + await asyncio.to_thread(mt_client.set_server_config, str(guild.id), server_config) await interaction.followup.send( f"Added rule: {metric.name} {threshold}+ -> @{role.name}", @@ -145,7 +170,7 @@ async def configure_roles( return server_config['role_rules'] = role_rules - mt_client.set_server_config(str(guild.id), server_config) + await asyncio.to_thread(mt_client.set_server_config, str(guild.id), server_config) await interaction.followup.send(f"Removed custom rules for @{role.name}.", ephemeral=True) return diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 5a80d5d..bbb409d 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -4,6 +4,7 @@ Handles Discord commands for managing GitHub to Discord notifications. """ +import asyncio import discord from discord import app_commands from typing import Literal @@ -18,56 +19,15 @@ def __init__(self, bot): def register_commands(self): """Register all notification commands with the bot.""" - self.bot.tree.add_command(self._set_webhook_command()) + # 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()) - 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: - # 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) - - 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.""" @@ -85,7 +45,11 @@ async def add_repo(interaction: discord.Interaction, repository: str): return # Add repository to monitoring list - success = WebhookManager.add_monitored_repository(repository) + success = await asyncio.to_thread( + WebhookManager.add_monitored_repository, + repository, + discord_server_id=str(interaction.guild_id) + ) if success: await interaction.followup.send( @@ -121,7 +85,11 @@ async def remove_repo(interaction: discord.Interaction, repository: str): return # Remove repository from monitoring list - success = WebhookManager.remove_monitored_repository(repository) + success = await asyncio.to_thread( + WebhookManager.remove_monitored_repository, + repository, + discord_server_id=str(interaction.guild_id) + ) if success: await interaction.followup.send( @@ -148,7 +116,10 @@ async def list_repos(interaction: discord.Interaction): await interaction.response.defer() try: - repositories = WebhookManager.get_monitored_repositories() + repositories = await asyncio.to_thread( + WebhookManager.get_monitored_repositories, + discord_server_id=str(interaction.guild_id) + ) embed = discord.Embed( title="CI/CD Monitoring Status", @@ -194,15 +165,29 @@ async def webhook_status(interaction: discord.Interaction): try: from shared.firestore import get_document - webhook_config = get_document('notification_config', 'webhooks') + webhook_config = await asyncio.to_thread( + 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 = 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( name="PR Automation Notifications", @@ -210,8 +195,14 @@ 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 = 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( name="CI/CD Notifications", @@ -219,11 +210,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 ) @@ -252,4 +250,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)) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index f05eba3..9cbacfc 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -7,10 +7,9 @@ import discord 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 ..auth import get_github_username_for_user, register_link_event, unregister_link_event, oauth_sessions, oauth_sessions_lock from shared.firestore import get_document, set_document, get_mt_client class UserCommands: @@ -18,7 +17,7 @@ class UserCommands: def __init__(self, bot): self.bot = bot - self.verification_lock = threading.Lock() + self._active_links: set[str] = set() # Per-user tracking, not global lock async def _safe_defer(self, interaction): """Safely defer interaction with error handling.""" @@ -62,16 +61,18 @@ def _link_command(self): async def link(interaction: discord.Interaction): await self._safe_defer(interaction) - if not self.verification_lock.acquire(blocking=False): - await self._safe_followup(interaction, "The verification process is currently busy. Please try again later.") + discord_user_id = str(interaction.user.id) + + if discord_user_id in self._active_links: + await self._safe_followup(interaction, "You already have a link process in progress. Please complete it or wait for it to expire.") return + self._active_links.add(discord_user_id) 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_user_data = await asyncio.to_thread(mt_client.get_user_mapping, discord_user_id) or {} existing_github = existing_user_data.get('github_id') existing_servers = existing_user_data.get('servers', []) @@ -79,7 +80,7 @@ async def link(interaction: discord.Interaction): 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 asyncio.to_thread(mt_client.set_user_mapping, discord_user_id, existing_user_data) await self._safe_followup( interaction, @@ -91,9 +92,29 @@ async def link(interaction: discord.Interaction): oauth_url = get_github_username_for_user(discord_user_id) 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 - ) + # Event-driven wait: no threads tied up, Flask callback wakes us instantly + link_event = asyncio.Event() + register_link_event(discord_user_id, link_event) + try: + await asyncio.wait_for(link_event.wait(), timeout=300) + except asyncio.TimeoutError: + # Clean up timed-out OAuth session + with oauth_sessions_lock: + oauth_sessions.pop(discord_user_id, None) + await self._safe_followup(interaction, "Authentication timed out or failed. Please try again.") + return + finally: + unregister_link_event(discord_user_id) + + # Event fired — read result from oauth_sessions + github_username = None + with oauth_sessions_lock: + session_data = oauth_sessions.pop(discord_user_id, None) + if session_data and session_data.get('status') == 'completed': + github_username = session_data.get('github_username') + elif session_data and session_data.get('status') == 'failed': + error = session_data.get('error', 'Unknown error') + print(f"OAuth failed for {discord_user_id}: {error}") if github_username: @@ -114,7 +135,7 @@ async def link(interaction: discord.Interaction): 'last_updated': str(interaction.created_at) } - mt_client.set_user_mapping(discord_user_id, user_data) + await asyncio.to_thread(mt_client.set_user_mapping, discord_user_id, user_data) await self._safe_followup( interaction, @@ -128,20 +149,20 @@ async def link(interaction: discord.Interaction): print("Error in /link:", e) await self._safe_followup(interaction, "Failed to link GitHub account.") finally: - self.verification_lock.release() + self._active_links.discard(discord_user_id) 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") + current_month = datetime.datetime.now(datetime.timezone.utc).strftime("%B") return { "pr_count": 0, "issues_count": 0, "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, @@ -184,12 +205,12 @@ async def unlink(interaction: discord.Interaction): discord_server_id = str(interaction.guild.id) mt_client = get_mt_client() - user_mapping = mt_client.get_user_mapping(discord_user_id) or {} + user_mapping = await asyncio.to_thread(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, {}) + await asyncio.to_thread(mt_client.set_user_mapping, discord_user_id, {}) await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") print(f"Unlinked Discord user {interaction.user.name}") @@ -224,19 +245,23 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): # Check global link mapping first discord_server_id = str(interaction.guild.id) mt_client = get_mt_client() - user_mapping = mt_client.get_user_mapping(user_id) or {} + user_mapping = await asyncio.to_thread(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_org = mt_client.get_org_from_server(discord_server_id) + github_org = await asyncio.to_thread(mt_client.get_org_from_server, discord_server_id) if not github_org: await self._safe_followup(interaction, "This server is not configured yet. Run `/setup` first.") 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 = await asyncio.to_thread(mt_client.get_org_document, github_org, 'contributions', github_username) + if not user_data: + metrics = await asyncio.to_thread(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) @@ -274,7 +299,7 @@ async def halloffame(interaction: discord.Interaction, type: str = "pr", period: try: discord_server_id = str(interaction.guild.id) - hall_of_fame_data = get_document('repo_stats', 'hall_of_fame', discord_server_id) + hall_of_fame_data = await asyncio.to_thread(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.") @@ -337,7 +362,7 @@ async def _create_stats_embed(self, user_data, github_username, stats_type, inte org_name = None if discord_server_id: try: - org_name = get_mt_client().get_org_from_server(discord_server_id) + org_name = await asyncio.to_thread(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}") diff --git a/discord_bot/src/bot/shared.py b/discord_bot/src/bot/shared.py new file mode 100644 index 0000000..7d39e46 --- /dev/null +++ b/discord_bot/src/bot/shared.py @@ -0,0 +1,7 @@ +""" +Shared module to store global references. +Allows communication between Flask OAuth thread and Discord Bot thread. +""" + +# Global reference to the Discord bot instance +bot_instance = None diff --git a/discord_bot/src/services/github_app_service.py b/discord_bot/src/services/github_app_service.py index a9775cb..783024d 100644 --- a/discord_bot/src/services/github_app_service.py +++ b/discord_bot/src/services/github_app_service.py @@ -74,7 +74,7 @@ def get_installation(self, installation_id: int) -> Optional[Dict[str, Any]]: return None def get_installation_access_token(self, installation_id: int) -> Optional[str]: - """Create a short-lived installation access token.""" + """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) @@ -87,3 +87,22 @@ def get_installation_access_token(self, installation_id: int) -> Optional[str]: print(f"Error creating access token for installation {installation_id}: {e}") return None + def find_installation_id(self, account_name: str) -> Optional[int]: + """Find installation ID for a specific account name (org or user).""" + 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 + except Exception as e: + print(f"Error finding installation for {account_name}: {e}") + return None + diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index b27da33..274e347 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -10,7 +10,7 @@ import json import logging from typing import Dict, Any, Optional, List -from datetime import datetime +from datetime import datetime, timezone from shared.firestore import get_document, set_document logger = logging.getLogger(__name__) @@ -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}") @@ -110,7 +100,7 @@ def _build_pr_automation_embed(self, pr_data: Dict[str, Any], comment_body: str) "title": f"PR #{pr_number} Automation Complete", "description": f"Automated processing completed for [{repo}](https://github.com/{repo}/pull/{pr_number})", "color": color, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "fields": [] } @@ -192,7 +182,7 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, "title": f"{config['emoji']} {config['title']}", "description": f"[{workflow_name}]({run_url}) in [{repo}](https://github.com/{repo})", "color": config['color'], - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "fields": [ { "name": "Repository", @@ -214,23 +204,46 @@ 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 = 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 = await asyncio.to_thread(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 = await asyncio.to_thread(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 + 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, @@ -247,28 +260,53 @@ 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.""" @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 = 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.now(timezone.utc).isoformat() + }) + + # Maintain legacy field for backward compatibility webhook_config[f'{notification_type}_webhook_url'] = webhook_url - webhook_config['last_updated'] = datetime.utcnow().isoformat() + webhook_config['last_updated'] = datetime.now(timezone.utc).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 = 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,28 +315,28 @@ 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 = 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: repos.append(repo) config['repositories'] = repos - config['last_updated'] = datetime.utcnow().isoformat() + config['last_updated'] = datetime.now(timezone.utc).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 = 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 @@ -306,9 +344,9 @@ def remove_monitored_repository(repo: str) -> bool: if repo in repos: repos.remove(repo) config['repositories'] = repos - config['last_updated'] = datetime.utcnow().isoformat() + config['last_updated'] = datetime.now(timezone.utc).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}") diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 62e5513..8b8f935 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,33 +43,29 @@ '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)' + }, + 'SECRET_KEY': { + 'required': True, + 'description': 'Flask session signing secret key (generate with: python3 -c "import secrets; print(secrets.token_hex(32))")' } } diff --git a/pr_review/main.py b/pr_review/main.py index 9ee4152..87a9de3 100644 --- a/pr_review/main.py +++ b/pr_review/main.py @@ -9,13 +9,29 @@ from typing import Dict, Any, List import json import asyncio +from pathlib import Path -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 +# 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)) + +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 @@ -35,7 +51,7 @@ def __init__(self): self.github_client = GitHubClient() self.metrics_calculator = MetricsCalculator() self.ai_labeler = AIPRLabeler() - self.reviewer_assigner = ReviewerAssigner() + self.reviewer_assigner = None # Will be initialized per request logger.info("PR Review System initialized successfully") @@ -44,7 +60,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 @@ -80,6 +96,8 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str # Step 4: Assign reviewers logger.info("Assigning reviewers...") + repo_owner = repo.split('/')[0] if '/' in repo else 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) @@ -105,10 +123,7 @@ def process_pull_request(self, repo: str, pr_number: int, experience_level: str self.github_client.create_issue_comment(repo, pr_number, comment_body) - # Send Discord notification - asyncio.create_task(self._send_discord_notification(results, comment_body)) - - # Return processing results + # Prepare results results = { 'pr_number': pr_number, 'repository': repo, @@ -119,19 +134,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 +231,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..82f0977 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.12.14 \ No newline at end of file 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 = [ 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""" 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..9974c71 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -168,16 +168,17 @@ def get_mt_client() -> FirestoreMultiTenant: '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 +193,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':