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/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/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..8a49f3f 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -3,10 +3,13 @@ name: Discord Bot Data Pipeline on: schedule: - cron: '0 0 * * *' # Run daily at midnight UTC - workflow_dispatch: {} # Allow manual trigger push: - branches: - - main + pull_request: + workflow_dispatch: + inputs: + organization: + description: "GitHub org to collect stats for" + required: true jobs: discord-bot-pipeline: @@ -41,31 +44,119 @@ 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 + - name: Collect GitHub Data for Multiple Organizations env: - GITHUB_TOKEN: ${{ secrets.DEV_GH_TOKEN }} - REPO_OWNER: ${{ secrets.REPO_OWNER }} + 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 }} + PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} 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') 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 services.github_app_service import GitHubAppService + + 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 + from firebase_admin import firestore + db = mt_client.db + 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:') + for server_id, server_data in servers.items(): + print(f' Server ID: {server_id}') + print(f' Data: {server_data}') + + # 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 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'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}') + + 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 = {} + 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}') + + 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,107 +167,126 @@ 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, github_org=github_org) + 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 + TARGET_ORG: ${{ github.event.inputs.organization }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }} 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 + import os - 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() + 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'] + 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}') + + # 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!') " - - 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 +295,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('discord_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('discord_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_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 diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 261ee51..40a03f5 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: @@ -41,7 +39,7 @@ on: default: 'process_pr' description: 'Action to perform' secrets: - DEV_GH_TOKEN: + GH_TOKEN: required: true GOOGLE_API_KEY: required: true @@ -63,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 @@ -71,7 +69,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: | @@ -80,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 }} 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/MAINTAINER.md b/MAINTAINER.md new file mode 100644 index 0000000..a7e3425 --- /dev/null +++ b/MAINTAINER.md @@ -0,0 +1,141 @@ +# 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. + +## 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. + +### `/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 + +### 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`: 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. + +--- + +## 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/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..cafd0e7 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -1,5 +1,24 @@ # 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. + +**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 @@ -105,11 +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) -- `REPO_OWNER=` (Your GitHub organization name) -- `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) +- `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) **Additional files you need:** - `discord_bot/config/credentials.json` (Firebase/Google Cloud credentials) @@ -117,10 +138,15 @@ 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` + +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` + --- @@ -167,6 +193,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) @@ -214,26 +244,9 @@ 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) - -**What this configures:** -- `.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. - -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` @@ -259,8 +272,18 @@ 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.) -### Step 5: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) +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 4: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) **What this configures:** - `.env` file: `GITHUB_CLIENT_ID=your_client_id` @@ -273,33 +296,56 @@ Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions - 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: - Homepage URL: `https://discord-bot-abcd1234-uc.a.run.app` - Callback URL: `https://discord-bot-abcd1234-uc.a.run.app/login/github/authorized` + - After OAuth completes, the app will redirect users to `/auth/callback` for the success page. 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 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 GitHub organization's repositories to monitor for contributions. +### 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=...` +- 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` +2. **Set these URLs:** + - **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. +4. **Permissions (read-only):** + - Metadata (required), Contents, Issues, Pull requests + - Webhooks: OFF +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` +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) + +**Security note:** Never commit the private key or base64 value to git. Treat it like a password. -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`) -2. **Set in Configuration:** - - **Add to `.env`:** `REPO_OWNER=your_org_name` (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 --- @@ -343,9 +389,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 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. --- @@ -409,7 +456,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 +466,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 +475,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}") @@ -686,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 e06e02d..b404df1 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -1,6 +1,12 @@ DISCORD_BOT_TOKEN= -GITHUB_TOKEN= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +OAUTH_BASE_URL= +DISCORD_BOT_CLIENT_ID= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY_B64= +GITHUB_APP_SLUG= +SECRET_KEY= REPO_OWNER= -OAUTH_BASE_URL= \ No newline at end of file +REPO_NAME= +WORKFLOW_REF= 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 9ef76de..5cc8a61 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,35 +333,52 @@ 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 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 - + + # 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 + + # SECRET_KEY (auto-generate if left blank) + echo -e "${BLUE}SECRET_KEY is used to sign session cookies (required for security).${NC}" + 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" + 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 -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 +REPO_OWNER=$repo_owner +REPO_NAME=$repo_name +WORKFLOW_REF=$workflow_ref EOF print_success ".env file created successfully!" @@ -341,29 +394,60 @@ 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} - + + 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} + + 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 "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 -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 +REPO_OWNER=$repo_owner +REPO_NAME=$repo_name +WORKFLOW_REF=$workflow_ref EOF print_success ".env file updated successfully!" @@ -469,7 +553,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 +573,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 @@ -669,6 +753,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 \ @@ -681,6 +774,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 @@ -737,4 +834,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..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 @@ -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 0106a22..a0915b9 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,10 +1,17 @@ import os +from typing import Optional +from datetime import datetime, timedelta, timezone +from shared.firestore import get_mt_client import threading import time -from flask import Flask, redirect, url_for, jsonify, session +import hmac +import hashlib +import requests +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 +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired load_dotenv() @@ -12,6 +19,384 @@ 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_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() + + # --- 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 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} + + token = gh_app.get_installation_access_token(pipeline_installation_id) + + if not token: + 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} + + # 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}", + "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): + _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}") + _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"] + 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: + """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 %} +
+ {{ button_text }} +
+ {% 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. @@ -34,17 +419,364 @@ 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(): 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/", + "github_app_install": "/github/app/install", + "github_app_setup_callback": "/github/app/setup", + "github_webhook": "/github/webhook" + } }) + @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: + 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({ + "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""" + + # 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 + 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" + ) + + + + # Enhanced landing page with modern design + landing_page = f""" + + + + 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. +
+
+ +
+
2
+
+ Configure: Automatic redirect after authorization. +
+
+ +
+
3
+
+ Track: Install the App on your repositories. +
+
+ +
+
4
+
+ Link: Users run /link in your Discord server. +
+
+
+ +
+
πŸ“Š Stats
+
πŸ€– Auto Roles
+
πŸ“ˆ Analytics
+
πŸ”Š Updates
+
+
+ + +""" + + return landing_page + @app.route("/auth/start/") def start_oauth(discord_user_id): """Start OAuth flow for a specific Discord user""" @@ -70,13 +802,19 @@ 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 - + 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") with oauth_sessions_lock: @@ -84,9 +822,13 @@ def github_callback(): 'status': 'failed', 'error': 'GitHub authorization failed' } - return "GitHub authorization failed", 400 - - # Get GitHub user info + _notify_link_event(discord_user_id) + 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: print(f"GitHub API call failed: {resp.status_code}") @@ -95,11 +837,16 @@ def github_callback(): 'status': 'failed', 'error': 'Failed to fetch GitHub user info' } - return "Failed to fetch GitHub user information", 400 - + _notify_link_event(discord_user_id) + 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") - + if not github_username: print("No GitHub username found") with oauth_sessions_lock: @@ -107,38 +854,847 @@ def github_callback(): 'status': 'failed', 'error': 'No GitHub username found' } - return "Failed to get GitHub username", 400 - - # Store successful result + _notify_link_event(discord_user_id) + 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] = { 'status': 'completed', - 'github_username': github_username, - 'github_user_data': github_user + 'github_username': github_username } - + _notify_link_event(discord_user_id) + + session.pop('discord_user_id', None) + print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") + + 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}") + return f"Authentication failed: {str(e)}", 500 + + @app.route("/github/app/install") + def github_app_install(): + """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 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 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) + + @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, timedelta + from src.services.github_app_service import GitHubAppService + + installation_id = request.args.get('installation_id') + 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: + 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': + # 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 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 f""" + 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 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 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 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 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') + github_account_type = account.get('type') + + github_org = github_account + 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, + 'github_account_type': github_account_type, + 'setup_source': 'github_app', + 'created_at': datetime.now(timezone.utc).isoformat(), + 'setup_completed': True + }) + + if not success: + 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 + sync_triggered = trigger_initial_sync(guild_id, github_org, int(installation_id)) + notify_setup_complete(guild_id, github_org) + + success_page = """ + + + + Setup Completed! + + + + + + + + +
+
+
+ +

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 %} +
+
+ + +
+ + + """ + + return render_template_string( + success_page, + guild_name=guild_name, + github_org=github_org, + is_personal_install=is_personal_install, + sync_triggered=sync_triggered + ) + + @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 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})}" + + setup_page = """ + + + + DisgitBot Setup + + + + + + + + +
+
+

DisgitBot Added!

+

Bot has been successfully added to {{ guild_name }}

+
+ +
+ +
+

Install the GitHub App

+

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

+ + + + + + Install GitHub App + +
+ + +
+ + + """ + + 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(): + """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') + 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 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 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 + 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(timezone.utc).isoformat(), + 'setup_completed': True + }) + + if not success: + 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 + trigger_initial_sync(guild_id, github_org) + notify_setup_complete(guild_id, github_org) + + success_page = """ + - 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.

- + + Setup Completed! + + + + + + + + +
+
+
+ +

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. +
+
+ + +
""" + return render_template_string(success_page, github_org=github_org) + except Exception as e: - print(f"Error in OAuth callback: {e}") - return f"Authentication failed: {str(e)}", 500 + print(f"Error in complete_setup: {e}") + return render_status_page( + title="Setup Failed", + subtitle="An unexpected error occurred during setup. Please try again.", + icon_type="error" + ), 500 return app @@ -150,33 +1706,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 d810060..b1a1604 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -4,13 +4,16 @@ 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 -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.""" @@ -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.""" @@ -41,6 +48,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,20 +56,93 @@ async def on_ready(): try: synced = await self.bot.tree.sync() print(f"{self.bot.user} is online! Synced {len(synced)} command(s).") + 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 (offload to thread to avoid blocking) + from shared.firestore import get_mt_client + mt_client = get_mt_client() + 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 + + # 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") + 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!** + +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` + +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) + + # 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: + print(f"Error sending setup guidance for guild {guild.id}: {e}") + import traceback + traceback.print_exc() + + def _register_commands(self): """Register all command modules.""" user_commands = UserCommands(self.bot) 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") @@ -73,4 +154,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 030a4cd..a9cad97 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 @@ -17,17 +18,20 @@ 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._sync_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()) - self.bot.tree.add_command(self._list_reviewers_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): """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" @@ -49,7 +53,177 @@ 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 connect 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" + + # Check existing configuration + from shared.firestore import get_mt_client + mt_client = get_mt_client() + 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( + 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?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" + + setup_message = f"""**DisgitBot Setup Required** + +Your server needs to connect a GitHub organization. + +**Steps:** +1. Visit: {setup_url} +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 + +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 _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") @@ -60,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") @@ -85,7 +275,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 = 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} @@ -107,7 +298,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 = 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)}") @@ -131,7 +322,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 = 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 @@ -155,7 +347,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 = 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)}") @@ -183,8 +375,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 = 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", @@ -247,4 +440,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/analytics_commands.py b/discord_bot/src/bot/commands/analytics_commands.py index bc3731c..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,18 +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: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.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) @@ -52,18 +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: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.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) @@ -80,18 +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: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.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) @@ -108,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)", @@ -130,13 +138,15 @@ 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 = 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 new file mode 100644 index 0000000..af3c17b --- /dev/null +++ b/discord_bot/src/bot/commands/config_commands.py @@ -0,0 +1,196 @@ +""" +Configuration Commands Module + +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.""" + + 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 = 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 + + 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 + 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 + + 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 + + # 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, []) + + # 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 + 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}", + 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 + 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 + + 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/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 7d384b1..a359649 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -4,11 +4,13 @@ Handles Discord commands for managing GitHub to Discord notifications. """ +import asyncio import discord from discord import app_commands 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.""" @@ -18,56 +20,17 @@ def __init__(self, bot): def register_commands(self): """Register all notification commands with the bot.""" - self.bot.tree.add_command(self._set_webhook_command()) - 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()) + # 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 - 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.""" @@ -84,8 +47,32 @@ 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 = 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( @@ -120,8 +107,31 @@ 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 = 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 +158,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,36 +207,63 @@ 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 - pr_status = "βœ… Configured" if pr_webhook else "❌ Not configured" + # 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", value=pr_status, inline=True ) - # 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" + # 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", value=cicd_status, 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 +292,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 6e8f57e..9481aae 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -7,95 +7,325 @@ 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 shared.firestore import get_document, set_document +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: """Handles user-related Discord commands.""" - + 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.""" + 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.""" + 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 + except discord.errors.HTTPException as exc: + if exc.code == 40060: + return + raise 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.""" @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) + + discord_user_id = str(interaction.user.id) - if not self.verification_lock.acquire(blocking=False): - await interaction.followup.send("The verification process is currently busy. Please try again later.", ephemeral=True) + 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 = 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', []) + + if existing_github: + if discord_server_id not in existing_servers: + existing_servers.append(discord_server_id) + existing_user_data['servers'] = existing_servers + await asyncio.to_thread(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 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 - ) + # 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: - set_document('discord', discord_user_id, { + + # 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' - }) - - # 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) + '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) + } + + await asyncio.to_thread(mt_client.set_user_mapping, discord_user_id, user_data) + + await self._safe_followup( + interaction, + f"Successfully linked to GitHub user: `{github_username}`\n" + f"Use `/getstats` to view your contribution data." + ) 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() + self._active_links.discard(discord_user_id) return link + + 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.now(datetime.timezone.utc).strftime("%B") + return { + "pr_count": 0, + "issues_count": 0, + "commits_count": 0, + "stats": { + "current_month": current_month, + "last_updated": last_updated or "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.""" @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) - user_data = get_document('discord', str(interaction.user.id)) + discord_user_id = str(interaction.user.id) + discord_server_id = str(interaction.guild.id) + mt_client = get_mt_client() - if user_data: - # Delete document by setting it to empty (Firestore will remove it) - set_document('discord', str(interaction.user.id), {}) - await interaction.followup.send( - "Successfully unlinked your Discord account from your GitHub username.", - ephemeral=True - ) - 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 - ) + 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 + + 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}") 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 @@ -109,47 +339,49 @@ 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_user_data = get_document('discord', user_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 - ) + + # Check global link mapping first + discord_server_id = str(interaction.guild.id) + mt_client = get_mt_client() + 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_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 - ) + + 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 = 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) 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 @@ -169,22 +401,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() - - hall_of_fame_data = get_document('repo_stats', 'hall_of_fame') - - 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 = 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.") + 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 @@ -219,19 +460,28 @@ 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 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 = 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}") + + 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() ) @@ -302,36 +552,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 \ No newline at end of file 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/pipeline/processors/reviewer_processor.py b/discord_bot/src/pipeline/processors/reviewer_processor.py index 3d541a1..fa02b4f 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 + + +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 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) 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 + } 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..e4c16c8 --- /dev/null +++ b/discord_bot/src/services/github_app_service.py @@ -0,0 +1,110 @@ +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 + + 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 [] + return resp.json() + except Exception as e: + print(f"Error listing installations: {e}") + return [] + diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index f55323f..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): + 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.repo_owner = os.getenv('REPO_OWNER', 'ruxailab') - - if not self.token: - raise ValueError("GITHUB_TOKEN environment variable is required") + self.token = token or os.getenv('GITHUB_TOKEN') + self.repo_owner = repo_owner or os.getenv('REPO_OWNER', 'ruxailab') + 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/services/guild_service.py b/discord_bot/src/services/guild_service.py index 02f748b..dadef3b 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,19 @@ 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 + role_rules = server_config.get('role_rules') if server_config else {} success = False @@ -41,15 +46,24 @@ 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, + role_rules or {} + ) + 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,18 +85,44 @@ 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, + 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") 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() 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: @@ -109,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: @@ -123,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 @@ -153,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 = [ @@ -200,4 +287,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/notification_service.py b/discord_bot/src/services/notification_service.py index 96dd30d..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": [] } @@ -180,19 +170,19 @@ 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']}", "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('notification_config', '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('notification_config', '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('notification_config', '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('notification_config', 'monitored_repos') + 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('notification_config', 'monitored_repos') 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('notification_config', 'monitored_repos', 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('notification_config', 'monitored_repos') + 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('notification_config', 'monitored_repos', 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/services/role_service.py b/discord_bot/src/services/role_service.py index 7698f00..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", @@ -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.""" @@ -148,10 +167,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.""" @@ -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" diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 6963535..05e51d8 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -35,10 +35,6 @@ 'required': True, 'description': 'Discord bot token for authentication' }, - 'GITHUB_TOKEN': { - 'required': True, - 'description': 'GitHub personal access token for API access' - }, 'GITHUB_CLIENT_ID': { 'required': True, 'description': 'GitHub OAuth application client ID' @@ -47,14 +43,44 @@ 'required': True, 'description': 'GitHub OAuth application client secret' }, - 'REPO_OWNER': { + 'OAUTH_BASE_URL': { 'required': True, - 'description': 'GitHub repository owner/organization name' + 'description': 'Base URL for OAuth redirects (your Cloud Run URL)' }, - 'OAUTH_BASE_URL': { + 'DISCORD_BOT_CLIENT_ID': { + 'required': True, + 'description': 'Discord application ID (client ID)' + }, + 'GITHUB_APP_ID': { + 'required': True, + 'description': 'GitHub App ID (required for SaaS mode)' + }, + 'GITHUB_APP_PRIVATE_KEY_B64': { + 'required': True, + 'description': 'Base64-encoded GitHub App private key PEM' + }, + 'GITHUB_APP_SLUG': { + '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))")' + }, + '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. 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, - '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)' + '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).' } } @@ -186,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 @@ -259,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) @@ -422,4 +443,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() 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/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 8b8703e..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 pr_config/reviewers") - reviewer_data = get_document('pr_config', 'reviewers') + 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('pr_config', 'reviewers', 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/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 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..9974c71 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('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('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('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('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}") + 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. @@ -58,58 +147,152 @@ 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: +# 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 + +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, 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 github_org: + 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': + if discord_server_id: + raise ValueError("discord_users is global; do not pass discord_server_id") + return mt_client.get_user_mapping(document_id) + + 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 -def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: bool = False) -> bool: - """Set a document in Firestore.""" - try: + 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, 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 github_org: + 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': + 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) + + 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 -def update_document(collection: str, document_id: str, data: Dict[str, Any]) -> bool: - """Update a document in Firestore.""" - try: + 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 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}") + return mt_client.update_org_document(github_org, collection, document_id, data) + + 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) + + 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 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() + 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() + + 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}") + mt_client.db.collection('organizations').document(github_org).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: + 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}") + +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: + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + return mt_client.query_org_collection(github_org, collection, filters) + + 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 {} \ No newline at end of file + 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}