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/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..6ed7753 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,14 @@ cp discord_bot/config/.env.example discord_bot/config/.env **Your `.env` file needs these values:** - `DISCORD_BOT_TOKEN=` (Discord bot authentication) -- `GITHUB_TOKEN=` (GitHub API access) +- `GITHUB_TOKEN=` (Github API access) - `GITHUB_CLIENT_ID=` (GitHub OAuth app ID) - `GITHUB_CLIENT_SECRET=` (GitHub OAuth app secret) -- `REPO_OWNER=` (Your GitHub organization name) +- `GITHUB_APP_ID=` (GitHub App ID) +- `GITHUB_APP_PRIVATE_KEY_B64=` (GitHub App private key, base64) +- `GITHUB_APP_SLUG=` (GitHub App slug) - `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) +- `REPO_OWNER=` (Owner of the Disgitbot repo that hosts the workflow dispatch. Ex: ruxailab) **Additional files you need:** - `discord_bot/config/credentials.json` (Firebase/Google Cloud credentials) @@ -117,10 +139,18 @@ cp discord_bot/config/.env.example discord_bot/config/.env **GitHub repository secrets you need to configure:** Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions β†’ Click "New repository secret" for each: - `DISCORD_BOT_TOKEN` -- `GH_TOKEN` +- `GH_TOKEN` - `GOOGLE_CREDENTIALS_JSON` - `REPO_OWNER` - `CLOUD_RUN_URL` +- `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` + +> The workflows only reference `GH_TOKEN`, so you can reuse the same PAT for all branches. --- @@ -167,6 +197,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,6 +248,7 @@ Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions - Paste the JSON content and encode it to base64 - Copy the base64 string - **Add to GitHub Secrets:** Create secret named `GOOGLE_CREDENTIALS_JSON` with the base64 string + - *(Do this for non-main branches)* Create another secret named `DEV_GOOGLE_CREDENTIALS_JSON` with the same base64 string so development branches can run GitHub Actions. ### Step 3: Get GITHUB_TOKEN (.env) + GH_TOKEN (GitHub Secret) @@ -221,7 +256,7 @@ Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions - `.env` file: `GITHUB_TOKEN=your_token_here` - GitHub Secret: `GH_TOKEN` -**What this does:** Allows the bot to access GitHub API to fetch repository and contribution data. +**What this does:** Allows the bot to access dispatch the Github Actions Workflow 1. **Go to GitHub Token Settings:** https://github.com/settings/tokens 2. **Create New Token:** @@ -259,6 +294,16 @@ Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions - **Add to `.env`:** `OAUTH_BASE_URL=YOUR_CLOUD_RUN_URL` - **Example:** `OAUTH_BASE_URL=https://discord-bot-abcd1234-uc.a.run.app` - **Add to GitHub Secrets:** Create secret named `CLOUD_RUN_URL` with the same URL + - *(Do this for non-main branches)* Create a `DEV_CLOUD_RUN_URL` pointing to the staging/test Cloud Run service so development workflows continue to function. (You may reuse CLOUD_RUN_URL if you are not deploying production from main.) + +3. **Configure Discord OAuth Redirect URI:** + - Go to [Discord Developer Portal](https://discord.com/developers/applications) + - Select your bot application (same one from Step 1) + - Go to **OAuth2** β†’ **General** + - In the **Redirects** section, click **Add Redirect** + - Add: `YOUR_CLOUD_RUN_URL/setup` + - **Example:** `https://discord-bot-abcd1234-uc.a.run.app/setup` + - Click **Save Changes** ### Step 5: Get GITHUB_CLIENT_ID (.env) + GITHUB_CLIENT_SECRET (.env) @@ -279,25 +324,63 @@ Go to your GitHub repository β†’ Settings β†’ Secrets and variables β†’ Actions **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 5b: Create GitHub App (GITHUB_APP_ID / PRIVATE_KEY / SLUG) + +**What this configures:** +- `.env` file: `GITHUB_APP_ID=...`, `GITHUB_APP_PRIVATE_KEY_B64=...`, `GITHUB_APP_SLUG=...` +- GitHub Secrets: `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. + ### 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. +**What this does:** Tells the bot which Disgitbot repo owns the GitHub Actions workflow (used for workflow dispatch). The org you track comes from GitHub App installation during `/setup`. -1. **Find Your Organization Name:** - - Go to your organization's repositories page (example: `https://github.com/orgs/ruxailab/repositories`) - - The organization name is the part after `/orgs/` (example: `ruxailab`) +1. **Find the Disgitbot repo owner:** + - Example repo: `https://github.com/ruxailab/disgitbot` + - The owner is the first path segment (`ruxailab`) 2. **Set in Configuration:** - - **Add to `.env`:** `REPO_OWNER=your_org_name` (example: `REPO_OWNER=ruxailab`) + - **Add to `.env`:** `REPO_OWNER=your_repo_owner` (example: `REPO_OWNER=ruxailab`) - **Add to GitHub Secrets:** Create secret named `REPO_OWNER` with the same value - **Important:** Use ONLY the organization name, NOT the full URL @@ -343,9 +426,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 +493,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 +503,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 +512,12 @@ def run_discord_bot_async(): **File: `discord_bot/main.py` (Lines 64-75)** ```python # Start Discord bot in a separate thread -print("🧡 Setting up Discord bot thread...") +print("Setting up Discord bot thread...") def start_discord_bot(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - print("πŸ€– Starting Discord bot in thread...") + print("Starting Discord bot in thread...") run_discord_bot_async() except Exception as e: print(f" Discord bot error: {e}") diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index e06e02d..ebf50d6 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -3,4 +3,8 @@ GITHUB_TOKEN= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= REPO_OWNER= -OAUTH_BASE_URL= \ No newline at end of file +OAUTH_BASE_URL= +DISCORD_BOT_CLIENT_ID= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY_B64= +GITHUB_APP_SLUG= diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 9ef76de..96842f7 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -12,6 +12,11 @@ PURPLE='\033[0;35m' CYAN='\033[0;36m' NC='\033[0m' # No Color +FZF_AVAILABLE=0 +if command -v fzf &>/dev/null; then + FZF_AVAILABLE=1 +fi + # Helper functions print_header() { echo -e "\n${PURPLE}================================${NC}" @@ -43,6 +48,12 @@ ENV_PATH="$ROOT_DIR/config/.env" print_header +if [ "$FZF_AVAILABLE" -eq 1 ]; then + print_success "fzf detected: you can type to filter options in selection menus." +else + print_warning "fzf not detected; falling back to arrow-key menu navigation." +fi + # Check if gcloud is installed and authenticated print_step "Checking Google Cloud CLI..." if ! command -v gcloud &> /dev/null; then @@ -132,6 +143,31 @@ interactive_select() { done } +fuzzy_select_or_fallback() { + local prompt="$1" + shift + local options=("$@") + + if [ "$FZF_AVAILABLE" -eq 1 ]; then + local selection + selection=$(printf '%s\n' "${options[@]}" | fzf --prompt="$prompt> " --height=15 --border --exit-0) + if [ -z "$selection" ]; then + print_warning "Selection cancelled." + exit 0 + fi + for i in "${!options[@]}"; do + if [[ "${options[$i]}" == "$selection" ]]; then + INTERACTIVE_SELECTION=$i + return + fi + done + print_error "Unable to match selection." + exit 1 + else + interactive_select "$prompt" "${options[@]}" + fi +} + # Function to select Google Cloud Project select_project() { print_step "Fetching your Google Cloud projects..." @@ -156,7 +192,7 @@ select_project() { done <<< "$projects" # Interactive selection - interactive_select "Select a Google Cloud Project:" "${project_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Project" "${project_options[@]}" selection=$INTERACTIVE_SELECTION PROJECT_ID="${project_ids[$selection]}" @@ -297,14 +333,8 @@ create_new_env_file() { print_warning "Discord Bot Token is required!" done - # GitHub Token - while true; do - read -p "GitHub Token: " github_token - if [ -n "$github_token" ]; then - break - fi - print_warning "GitHub Token is required!" - done + # GitHub Token (optional for GitHub App mode) + read -p "GitHub Token (optional): " github_token # GitHub Client ID read -p "GitHub Client ID: " github_client_id @@ -317,6 +347,14 @@ create_new_env_file() { # OAuth Base URL (optional - will auto-detect on Cloud Run) read -p "OAuth Base URL (optional): " oauth_base_url + + # Discord Bot Client ID + read -p "Discord Bot Client ID: " discord_bot_client_id + + # GitHub App configuration (invite-only mode) + read -p "GitHub App ID: " github_app_id + read -p "GitHub App Private Key (base64): " github_app_private_key_b64 + read -p "GitHub App Slug: " github_app_slug # Create .env file cat > "$ENV_PATH" << EOF @@ -326,6 +364,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file created successfully!" @@ -355,6 +397,18 @@ edit_env_file() { read -p "OAuth Base URL [$OAUTH_BASE_URL]: " new_oauth_base_url oauth_base_url=${new_oauth_base_url:-$OAUTH_BASE_URL} + + read -p "Discord Bot Client ID [$DISCORD_BOT_CLIENT_ID]: " new_discord_bot_client_id + discord_bot_client_id=${new_discord_bot_client_id:-$DISCORD_BOT_CLIENT_ID} + + read -p "GitHub App ID [$GITHUB_APP_ID]: " new_github_app_id + github_app_id=${new_github_app_id:-$GITHUB_APP_ID} + + read -p "GitHub App Private Key (base64) [$GITHUB_APP_PRIVATE_KEY_B64]: " new_github_app_private_key_b64 + github_app_private_key_b64=${new_github_app_private_key_b64:-$GITHUB_APP_PRIVATE_KEY_B64} + + read -p "GitHub App Slug [$GITHUB_APP_SLUG]: " new_github_app_slug + github_app_slug=${new_github_app_slug:-$GITHUB_APP_SLUG} # Update .env file cat > "$ENV_PATH" << EOF @@ -364,6 +418,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file updated successfully!" @@ -469,7 +527,7 @@ get_deployment_config() { "custom" ) - interactive_select "Select a Google Cloud Region:" "${region_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Region" "${region_options[@]}" region_choice=$INTERACTIVE_SELECTION if [ $region_choice -eq 5 ]; then # Custom region @@ -489,7 +547,7 @@ get_deployment_config() { declare -a memory_values=("512Mi" "1Gi" "2Gi" "custom") declare -a cpu_values=("1" "1" "2" "custom") - interactive_select "Select Resource Configuration:" "${resource_options[@]}" + fuzzy_select_or_fallback "Select Resource Configuration" "${resource_options[@]}" resource_choice=$INTERACTIVE_SELECTION if [ $resource_choice -eq 3 ]; then # Custom @@ -737,4 +795,4 @@ main() { } # Run main function -main \ No newline at end of file +main diff --git a/discord_bot/requirements.txt b/discord_bot/requirements.txt index 82bac54..903042f 100644 --- a/discord_bot/requirements.txt +++ b/discord_bot/requirements.txt @@ -10,3 +10,4 @@ python-dateutil==2.8.2 Werkzeug==3.0.1 matplotlib>=3.9.2 numpy>=2.0.0 +PyJWT[crypto]==2.9.0 diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index 0106a22..97e5a14 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -1,10 +1,12 @@ import os import threading import time +import requests from flask import Flask, redirect, url_for, jsonify, session from flask_dance.contrib.github import make_github_blueprint, github from dotenv import load_dotenv from werkzeug.middleware.proxy_fix import ProxyFix +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired load_dotenv() @@ -34,16 +36,164 @@ 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" + } }) + + @app.route("/debug/servers") + def debug_servers(): + """Debug endpoint to see registered servers""" + try: + from shared.firestore import get_mt_client + + mt_client = get_mt_client() + + # Get all servers + servers_ref = mt_client.db.collection('discord_servers') + servers = [] + + for doc in servers_ref.stream(): + server_data = doc.to_dict() + servers.append({ + 'server_id': doc.id, + 'data': server_data + }) + + return jsonify({ + "total_servers": len(servers), + "servers": servers + }) + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/invite") + def invite_bot(): + """Discord bot invitation endpoint""" + from flask import render_template_string + + # Your bot's client ID from Discord Developer Portal + bot_client_id = os.getenv("DISCORD_BOT_CLIENT_ID", "YOUR_BOT_CLIENT_ID") + + # Required permissions for the bot + # Updated permissions to match working invite link + permissions = "552172899344" # Manage Roles + View Channels + Send Messages + Use Slash Commands + + discord_invite_url = ( + f"https://discord.com/oauth2/authorize?" + f"client_id={bot_client_id}&" + f"permissions={permissions}&" + f"integration_type=0&" + f"scope=bot+applications.commands" + ) + + # Enhanced landing page with clear instructions + landing_page = f""" + + + + Add DisgitBot to Discord + + + + + +
+

Add DisgitBot to Discord

+

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

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

Setup Instructions (Required)

+
+ Step 1: Click "Add Bot to Discord" above +
+
+ Step 2: After adding the bot, visit this setup URL: +
{base_url}/setup
+
+
+ Step 3: Install the GitHub App and select repositories +
+
+ Step 4: Users can link GitHub accounts with /link in Discord +
+
+ +

Features:

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

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

+
+ + + """ + + return render_template_string(landing_page, discord_invite_url=discord_invite_url) @app.route("/auth/start/") def start_oauth(discord_user_id): @@ -70,13 +220,13 @@ def start_oauth(discord_user_id): @app.route("/auth/callback") def github_callback(): - """Handle GitHub OAuth callback - original working version""" + """Handle GitHub OAuth callback for user account linking.""" try: discord_user_id = session.get('discord_user_id') if not discord_user_id: return "Authentication failed: No Discord user session", 400 - + if not github.authorized: print("GitHub OAuth not authorized") with oauth_sessions_lock: @@ -85,8 +235,7 @@ def github_callback(): 'error': 'GitHub authorization failed' } return "GitHub authorization failed", 400 - - # Get GitHub user info + resp = github.get("/user") if not resp.ok: print(f"GitHub API call failed: {resp.status_code}") @@ -96,10 +245,10 @@ def github_callback(): 'error': 'Failed to fetch GitHub user info' } return "Failed to fetch GitHub user information", 400 - + github_user = resp.json() github_username = github_user.get("login") - + if not github_username: print("No GitHub username found") with oauth_sessions_lock: @@ -108,17 +257,17 @@ def github_callback(): 'error': 'No GitHub username found' } return "Failed to get GitHub username", 400 - - # Store successful result + with oauth_sessions_lock: oauth_sessions[discord_user_id] = { 'status': 'completed', - 'github_username': github_username, - 'github_user_data': github_user + 'github_username': github_username } - + + session.pop('discord_user_id', None) + print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") - + return f""" Authentication Successful @@ -135,10 +284,363 @@ def github_callback(): """ - + except Exception as e: print(f"Error in OAuth callback: {e}") return f"Authentication failed: {str(e)}", 500 + + @app.route("/github/app/install") + def github_app_install(): + """Redirect server owners to install the DisgitBot GitHub App.""" + from flask import request + + guild_id = request.args.get('guild_id') + guild_name = request.args.get('guild_name', 'your server') + + if not guild_id: + return "Error: No Discord server information received", 400 + + app_slug = os.getenv("GITHUB_APP_SLUG") + if not app_slug: + return "Server configuration error: missing GITHUB_APP_SLUG", 500 + + state = state_serializer.dumps({'guild_id': str(guild_id), 'guild_name': guild_name}) + + install_url = f"https://github.com/apps/{app_slug}/installations/new?state={state}" + return redirect(install_url) + + @app.route("/github/app/setup") + def github_app_setup(): + """GitHub App 'Setup URL' callback: stores installation ID for a Discord server.""" + from flask import request, render_template_string + from shared.firestore import get_mt_client + from datetime import datetime, timedelta + from src.services.github_app_service import GitHubAppService + + installation_id = request.args.get('installation_id') + state = request.args.get('state', '') + + if not installation_id or not state: + return "Missing installation_id or state", 400 + + try: + payload = state_serializer.loads(state, max_age=60 * 30) + except SignatureExpired: + return "Setup link expired. Please restart setup from Discord.", 400 + except BadSignature: + return "Invalid setup state. Please restart setup from Discord.", 400 + + guild_id = str(payload.get('guild_id', '')) + guild_name = payload.get('guild_name', 'your server') + if not guild_id: + return "Invalid setup state (missing guild_id). Please restart setup from Discord.", 400 + + gh_app = GitHubAppService() + installation = gh_app.get_installation(int(installation_id)) + if not installation: + return "Failed to fetch installation details from GitHub.", 500 + + account = installation.get('account') or {} + github_account = account.get('login') + github_account_type = account.get('type') + + github_org = github_account + is_personal_install = github_account_type == 'User' + + mt_client = get_mt_client() + 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().isoformat(), + 'setup_completed': True + }) + + if not success: + return "Error: Failed to save configuration", 500 + + def trigger_initial_sync(org_name: str) -> bool: + """Trigger the GitHub Actions pipeline once after setup.""" + token = os.getenv("GITHUB_TOKEN") + repo_owner = os.getenv("REPO_OWNER") + repo_name = os.getenv("REPO_NAME", "disgitbot") + ref = os.getenv("WORKFLOW_REF", "main") + + if not token or not repo_owner: + print("Skipping pipeline trigger: missing GITHUB_TOKEN or REPO_OWNER") + return False + + existing_config = mt_client.get_server_config(guild_id) or {} + last_trigger = existing_config.get("initial_sync_triggered_at") + if last_trigger: + try: + last_dt = datetime.fromisoformat(last_trigger) + if datetime.now() - last_dt < timedelta(minutes=10): + print("Skipping pipeline trigger: recent sync already triggered") + return False + except ValueError: + pass + + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/discord_bot_pipeline.yml/dispatches" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + } + payload = { + "ref": ref, + "inputs": { + "organization": org_name + } + } + + try: + resp = requests.post(url, headers=headers, json=payload, timeout=20) + if resp.status_code in (201, 204): + mt_client.set_server_config(guild_id, { + **existing_config, + "initial_sync_triggered_at": datetime.now().isoformat() + }) + return True + print(f"Failed to trigger pipeline: {resp.status_code} {resp.text[:200]}") + except Exception as exc: + print(f"Error triggering pipeline: {exc}") + return False + + sync_triggered = trigger_initial_sync(github_org) + + success_page = """ + + + + GitHub Connected! + + + + + +
+

GitHub Connected!

+

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

+ {% if is_personal_install %} +

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

+ {% endif %} + +

Next Steps in Discord

+

1) Users link their GitHub accounts:

+
/link
+

2) Configure custom roles:

+
/configure roles
+ {% if sync_triggered %} +

βœ… Initial sync started. Stats will appear shortly.

+ {% else %} +

⏳ Initial sync will run on the next scheduled pipeline.

+ {% endif %} +

3) Try these commands:

+
/getstats
+
/halloffame
+
+ + + """ + + 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 "Error: No Discord server information received", 400 + + github_app_install_url = f"{base_url}/github/app/install?{urlencode({'guild_id': guild_id, 'guild_name': guild_name})}" + + setup_page = """ + + + + DisgitBot Setup + + + + + +
+

DisgitBot Added Successfully!

+

Bot has been added to {{ guild_name }}

+ +

Recommended: Install the GitHub App

+

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

+ Install GitHub App + +

Manual Setup (disabled)

+

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

+ +

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

+
+ + + """ + + 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 "Error: Missing required information", 400 + + # Validate GitHub organization name (basic validation) + if not github_org.replace('-', '').replace('_', '').isalnum(): + return "Error: Invalid GitHub organization name", 400 + + try: + # Store server configuration + mt_client = get_mt_client() + success = mt_client.set_server_config(guild_id, { + 'github_org': github_org, + 'setup_source': setup_source, + 'created_at': datetime.now().isoformat(), + 'setup_completed': True + }) + + if not success: + return "Error: Failed to save configuration", 500 + + + success_page = """ + + + + Setup Complete! + + + + + +
+

Setup Complete!

+

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

+ +

Next Steps:

+

1. Return to Discord

+

2. Users can link their GitHub accounts with:

+
/link
+ +

3. Try these commands:

+
/getstats
+
/halloffame
+ +

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

+
+ + + """ + + return render_template_string(success_page, github_org=github_org) + + except Exception as e: + print(f"Error in complete_setup: {e}") + return f"Error: Setup failed - {str(e)}", 500 return app diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index d810060..ac3a002 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -10,7 +10,7 @@ from discord.ext import commands from dotenv import load_dotenv -from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands +from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands, ConfigCommands class DiscordBot: """Main Discord bot class with modular command registration.""" @@ -41,6 +41,7 @@ def _create_bot(self): """Create Discord bot instance.""" intents = discord.Intents.default() intents.message_content = True + intents.guilds = True # Required for on_guild_join event self.bot = commands.Bot(command_prefix="!", intents=intents) @self.bot.event @@ -48,8 +49,110 @@ async def on_ready(): try: synced = await self.bot.tree.sync() print(f"{self.bot.user} is online! Synced {len(synced)} command(s).") + + # Check for any unconfigured servers and notify them + await self._check_server_configurations() + except Exception as e: - print(f"Failed to sync commands: {e}") + print(f"Error in on_ready: {e}") + import traceback + traceback.print_exc() + + @self.bot.event + async def on_guild_join(guild): + """Called when bot joins a new server - provide setup guidance.""" + try: + # Check if server is already configured + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) + + if not server_config: + # Server not configured - send setup message to system channel + system_channel = guild.system_channel + if not system_channel: + # Fallback: find first available text channel + system_channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) + + if system_channel: + base_url = os.getenv("OAUTH_BASE_URL") + 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) + 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() + + async def _check_server_configurations(self): + """Check for any unconfigured servers and notify them.""" + try: + from shared.firestore import get_mt_client + import asyncio + + async def notify_unconfigured_servers(): + mt_client = get_mt_client() + + for guild in self.bot.guilds: + server_config = mt_client.get_server_config(str(guild.id)) + + if not server_config: + # Server not configured + system_channel = guild.system_channel + if not system_channel: + system_channel = next((ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages), None) + + if system_channel: + base_url = os.getenv("OAUTH_BASE_URL") + from urllib.parse import urlencode + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" + + setup_message = f"""️ **DisgitBot Setup Required** + +This server needs to be configured to track GitHub contributions. + +**Quick Setup (30 seconds):** +1. Visit: {setup_url} +2. Install the GitHub App and select repositories +3. Use `/link` in Discord to connect GitHub accounts +4. Customize roles with `/configure roles` + +**Or use this command:** `/setup` + +*This is a one-time setup message.*""" + + await system_channel.send(setup_message) + print(f"Sent setup reminder to server: {guild.name} (ID: {guild.id})") + + # Run the async function directly + await notify_unconfigured_servers() + + except Exception as e: + print(f"Error checking server configurations: {e}") + import traceback + traceback.print_exc() def _register_commands(self): """Register all command modules.""" @@ -57,11 +160,13 @@ def _register_commands(self): admin_commands = AdminCommands(self.bot) analytics_commands = AnalyticsCommands(self.bot) notification_commands = NotificationCommands(self.bot) + config_commands = ConfigCommands(self.bot) user_commands.register_commands() admin_commands.register_commands() analytics_commands.register_commands() notification_commands.register_commands() + config_commands.register_commands() print("All command modules registered") @@ -73,4 +178,4 @@ def run(self): def create_bot(): """Factory function to create Discord bot instance.""" - return DiscordBot() \ No newline at end of file + return DiscordBot() diff --git a/discord_bot/src/bot/commands/__init__.py b/discord_bot/src/bot/commands/__init__.py index 497a507..393f1f8 100644 --- a/discord_bot/src/bot/commands/__init__.py +++ b/discord_bot/src/bot/commands/__init__.py @@ -8,5 +8,6 @@ from .admin_commands import AdminCommands from .analytics_commands import AnalyticsCommands from .notification_commands import NotificationCommands +from .config_commands import ConfigCommands -__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands'] \ No newline at end of file +__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands', 'ConfigCommands'] diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index 030a4cd..259308a 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -17,6 +17,7 @@ def __init__(self, bot): def register_commands(self): """Register all admin commands with the bot.""" self.bot.tree.add_command(self._check_permissions_command()) + self.bot.tree.add_command(self._setup_command()) self.bot.tree.add_command(self._setup_voice_stats_command()) self.bot.tree.add_command(self._add_reviewer_command()) self.bot.tree.add_command(self._remove_reviewer_command()) @@ -49,6 +50,72 @@ 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 = 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 _setup_voice_stats_command(self): """Create the setup_voice_stats command.""" @@ -85,7 +152,8 @@ async def add_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration - reviewer_data = get_document('pr_config', 'reviewers') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) if not reviewer_data: reviewer_data = {'reviewers': [], 'manual_reviewers': [], 'top_contributor_reviewers': [], 'count': 0} @@ -107,7 +175,7 @@ async def add_reviewer(interaction: discord.Interaction, username: str): reviewer_data['last_updated'] = __import__('time').strftime('%Y-%m-%d %H:%M:%S UTC', __import__('time').gmtime()) # Save to Firestore - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('pr_config', 'reviewers', reviewer_data, discord_server_id=discord_server_id) if success: await interaction.followup.send(f"Successfully added `{username}` to the manual reviewer pool.\nTotal reviewers: {len(all_reviewers)}") @@ -131,7 +199,8 @@ async def remove_reviewer(interaction: discord.Interaction, username: str): try: # Get current reviewer configuration - reviewer_data = get_document('pr_config', 'reviewers') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) if not reviewer_data or not reviewer_data.get('reviewers'): await interaction.followup.send("No reviewers found in the database.") return @@ -155,7 +224,7 @@ async def remove_reviewer(interaction: discord.Interaction, username: str): reviewer_data['last_updated'] = __import__('time').strftime('%Y-%m-%d %H:%M:%S UTC', __import__('time').gmtime()) # Save to Firestore - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('pr_config', 'reviewers', reviewer_data, discord_server_id=discord_server_id) if success: await interaction.followup.send(f"Successfully removed `{username}` from the manual reviewer pool.\nTotal reviewers: {len(all_reviewers)}") @@ -183,8 +252,9 @@ async def list_reviewers(interaction: discord.Interaction): try: # Get reviewer data - reviewer_data = get_document('pr_config', 'reviewers') - contributor_data = get_document('repo_stats', 'contributor_summary') + discord_server_id = str(interaction.guild.id) + reviewer_data = get_document('pr_config', 'reviewers', discord_server_id) + contributor_data = get_document('repo_stats', 'contributor_summary', discord_server_id) embed = discord.Embed( title="PR Reviewer Pool Status", @@ -247,4 +317,4 @@ async def list_reviewers(interaction: discord.Interaction): import traceback traceback.print_exc() - return list_reviewers \ No newline at end of file + return list_reviewers diff --git a/discord_bot/src/bot/commands/analytics_commands.py b/discord_bot/src/bot/commands/analytics_commands.py index bc3731c..8b668b9 100644 --- a/discord_bot/src/bot/commands/analytics_commands.py +++ b/discord_bot/src/bot/commands/analytics_commands.py @@ -29,7 +29,8 @@ async def show_top_contributors(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -57,7 +58,8 @@ async def show_activity_comparison(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -85,7 +87,8 @@ async def show_activity_trends(interaction: discord.Interaction): await interaction.response.defer() try: - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) @@ -130,7 +133,8 @@ async def show_time_series(interaction: discord.Interaction, metrics: str = "prs await interaction.followup.send("Invalid metrics. Use: prs, issues, commits, total", ephemeral=True) return - analytics_data = get_document('repo_stats', 'analytics') + discord_server_id = str(interaction.guild.id) + analytics_data = get_document('repo_stats', 'analytics', discord_server_id) if not analytics_data: await interaction.followup.send("No analytics data available for analysis.", ephemeral=True) diff --git a/discord_bot/src/bot/commands/config_commands.py b/discord_bot/src/bot/commands/config_commands.py new file mode 100644 index 0000000..b5d1491 --- /dev/null +++ b/discord_bot/src/bot/commands/config_commands.py @@ -0,0 +1,171 @@ +""" +Configuration Commands Module + +Server configuration commands for role mappings and setup checks. +""" + +import discord +from discord import app_commands +from shared.firestore import get_mt_client + + +class ConfigCommands: + """Handles configuration commands for server administrators.""" + + def __init__(self, bot): + self.bot = bot + + def register_commands(self): + """Register configuration commands with the bot.""" + configure_group = app_commands.Group( + name="configure", + description="Configure DisgitBot settings for this server" + ) + + @configure_group.command( + name="roles", + description="Manage custom role mappings by contributions" + ) + @app_commands.describe( + action="Choose an action", + metric="Contribution type to map", + threshold="Minimum count required", + role="Discord role to grant" + ) + @app_commands.choices( + action=[ + app_commands.Choice(name="list", value="list"), + app_commands.Choice(name="add", value="add"), + app_commands.Choice(name="remove", value="remove"), + app_commands.Choice(name="reset", value="reset"), + ], + metric=[ + app_commands.Choice(name="prs", value="pr"), + app_commands.Choice(name="issues", value="issue"), + app_commands.Choice(name="commits", value="commit"), + ] + ) + async def configure_roles( + interaction: discord.Interaction, + action: app_commands.Choice[str], + metric: app_commands.Choice[str] | None = None, + threshold: int | None = None, + role: discord.Role | None = None + ): + await interaction.response.defer(ephemeral=True) + + if not interaction.user.guild_permissions.administrator: + await interaction.followup.send("Only server administrators can configure roles.", ephemeral=True) + return + + guild = interaction.guild + if not guild: + await interaction.followup.send("This command can only be used in a server.", ephemeral=True) + return + + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) or {} + if not server_config.get('setup_completed'): + await interaction.followup.send("Run `/setup` first to connect GitHub.", ephemeral=True) + return + + role_rules = server_config.get('role_rules') or { + 'pr': [], + 'issue': [], + 'commit': [] + } + + action_value = action.value + + if action_value == "list": + await interaction.followup.send(self._format_role_rules(role_rules), ephemeral=True) + return + + if action_value == "reset": + role_rules = {'pr': [], 'issue': [], 'commit': []} + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + await interaction.followup.send("Role rules reset to defaults.", ephemeral=True) + return + + if action_value == "add": + if not metric or threshold is None or not role: + await interaction.followup.send( + "Usage: `/configure roles action:add metric: threshold: role:@Role`", + ephemeral=True + ) + return + + if threshold <= 0: + await interaction.followup.send("Threshold must be a positive number.", ephemeral=True) + return + + metric_key = metric.value + rules = role_rules.get(metric_key, []) + + # Remove existing rule for this role to avoid duplicates + rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + + rules.append({ + 'threshold': int(threshold), + 'role_id': str(role.id), + 'role_name': role.name + }) + + rules = sorted(rules, key=lambda r: r.get('threshold', 0)) + role_rules[metric_key] = rules + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send( + f"Added rule: {metric.name} {threshold}+ -> @{role.name}", + ephemeral=True + ) + return + + if action_value == "remove": + if not role: + await interaction.followup.send( + "Usage: `/configure roles action:remove role:@Role`", + ephemeral=True + ) + return + + removed = False + for key in ('pr', 'issue', 'commit'): + rules = role_rules.get(key, []) + new_rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + if len(new_rules) != len(rules): + removed = True + role_rules[key] = new_rules + + if not removed: + await interaction.followup.send("That role is not in your custom rules.", ephemeral=True) + return + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send(f"Removed custom rules for @{role.name}.", ephemeral=True) + return + + await interaction.followup.send("Unknown action. Use list, add, remove, or reset.", ephemeral=True) + + self.bot.tree.add_command(configure_group) + + def _format_role_rules(self, role_rules: dict) -> str: + sections = [] + for key, label in (('pr', 'PRs'), ('issue', 'Issues'), ('commit', 'Commits')): + rules = role_rules.get(key, []) + if not rules: + sections.append(f"{label}: (no custom rules)") + continue + lines = [f"{label}:"] + for rule in sorted(rules, key=lambda r: r.get('threshold', 0)): + threshold = rule.get('threshold', 0) + role_name = rule.get('role_name', 'Unknown') + lines.append(f" - {threshold}+ -> @{role_name}") + sections.append("\n".join(lines)) + + return "Custom role rules:\n" + "\n\n".join(sections) diff --git a/discord_bot/src/bot/commands/notification_commands.py b/discord_bot/src/bot/commands/notification_commands.py index 7d384b1..5a80d5d 100644 --- a/discord_bot/src/bot/commands/notification_commands.py +++ b/discord_bot/src/bot/commands/notification_commands.py @@ -203,7 +203,7 @@ async def webhook_status(interaction: discord.Interaction): # Check PR automation webhook pr_webhook = webhook_config.get('pr_automation_webhook_url') if webhook_config else None - pr_status = "βœ… Configured" if pr_webhook else "❌ Not configured" + pr_status = "Configured" if pr_webhook else "Not configured" embed.add_field( name="PR Automation Notifications", value=pr_status, @@ -212,7 +212,7 @@ async def webhook_status(interaction: discord.Interaction): # Check CI/CD webhook cicd_webhook = webhook_config.get('cicd_webhook_url') if webhook_config else None - cicd_status = "βœ… Configured" if cicd_webhook else "❌ Not configured" + cicd_status = "Configured" if cicd_webhook else "Not configured" embed.add_field( name="CI/CD Notifications", value=cicd_status, diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 6e8f57e..f05eba3 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -8,16 +8,46 @@ 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 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() + + 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.""" @@ -30,72 +60,142 @@ def _link_command(self): """Create the link command.""" @app_commands.command(name="link", description="Link your Discord to GitHub") async def link(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) + await self._safe_defer(interaction) if not self.verification_lock.acquire(blocking=False): - await interaction.followup.send("The verification process is currently busy. Please try again later.", ephemeral=True) + await self._safe_followup(interaction, "The verification process is currently busy. Please try again later.") return try: discord_user_id = str(interaction.user.id) - + discord_server_id = str(interaction.guild.id) + mt_client = get_mt_client() + + existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} + existing_github = existing_user_data.get('github_id') + existing_servers = existing_user_data.get('servers', []) + + if existing_github: + if discord_server_id not in existing_servers: + existing_servers.append(discord_server_id) + existing_user_data['servers'] = existing_servers + mt_client.set_user_mapping(discord_user_id, existing_user_data) + + await self._safe_followup( + interaction, + f"Already linked to GitHub user: `{existing_github}`\n" + f"Use `/unlink` to disconnect and relink." + ) + return + oauth_url = get_github_username_for_user(discord_user_id) - await 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 ) 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) + } + + 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() return link + + def _empty_user_stats(self) -> dict: + """Return an empty stats payload for users with no synced data yet.""" + current_month = datetime.datetime.utcnow().strftime("%B") + return { + "pr_count": 0, + "issues_count": 0, + "commits_count": 0, + "stats": { + "current_month": current_month, + "last_updated": "Not synced yet", + "pr": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + }, + "issue": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + }, + "commit": { + "daily": 0, + "weekly": 0, + "monthly": 0, + "all_time": 0, + "current_streak": 0, + "longest_streak": 0, + "avg_per_day": 0 + } + }, + "rankings": {} + } def _unlink_command(self): """Create the unlink command.""" @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 = mt_client.get_user_mapping(discord_user_id) or {} + if not user_mapping.get('github_id'): + await self._safe_followup(interaction, "Your Discord account is not linked to any GitHub username.") + return + + mt_client.set_user_mapping(discord_user_id, {}) + await 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 +209,45 @@ def _getstats_command(self): app_commands.Choice(name="Commits", value="commit") ]) async def getstats(interaction: discord.Interaction, type: str = "pr"): - await interaction.response.defer() - + try: + await self._safe_defer(interaction) + except Exception: + pass + try: stats_type = type.lower().strip() if stats_type not in ["pr", "issue", "commit"]: stats_type = "pr" - + user_id = str(interaction.user.id) - - # Get user's Discord data to find their GitHub username - discord_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 = 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 = mt_client.get_org_from_server(discord_server_id) + if not github_org: + await self._safe_followup(interaction, "This server is not configured yet. Run `/setup` first.") return + # Fetch org-scoped stats for this GitHub username + user_data = mt_client.get_org_document(github_org, 'contributions', github_username) or self._empty_user_stats() + # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) 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 +267,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 = 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 +326,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 = 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 +418,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/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..a9775cb --- /dev/null +++ b/discord_bot/src/services/github_app_service.py @@ -0,0 +1,89 @@ +import base64 +import os +import time +from typing import Any, Dict, Optional + +import requests + + +class GitHubAppService: + """GitHub App authentication helpers (JWT + installation access tokens).""" + + def __init__(self): + self.api_url = "https://api.github.com" + self.app_id = os.getenv("GITHUB_APP_ID") + self._private_key_pem = self._load_private_key_pem() + + self._jwt_token: Optional[str] = None + self._jwt_exp: int = 0 + + if not self.app_id: + raise ValueError("GITHUB_APP_ID environment variable is required for GitHub App auth") + if not self._private_key_pem: + raise ValueError("GITHUB_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY_B64) is required for GitHub App auth") + + def _load_private_key_pem(self) -> str: + key = os.getenv("GITHUB_APP_PRIVATE_KEY", "") + if key: + return key.replace("\\n", "\n") + + key_b64 = os.getenv("GITHUB_APP_PRIVATE_KEY_B64", "") + if key_b64: + return base64.b64decode(key_b64).decode("utf-8") + + return "" + + def get_app_jwt(self) -> str: + """Create (or reuse) an app JWT.""" + now = int(time.time()) + if self._jwt_token and now < (self._jwt_exp - 60): + return self._jwt_token + + try: + import jwt # PyJWT + except Exception as e: + raise RuntimeError("PyJWT is required for GitHub App auth. Install PyJWT[crypto].") from e + + payload = { + "iat": now - 60, + "exp": now + 9 * 60, + "iss": self.app_id, + } + token = jwt.encode(payload, self._private_key_pem, algorithm="RS256") + self._jwt_token = token + self._jwt_exp = payload["exp"] + return token + + def _app_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.get_app_jwt()}", + "Accept": "application/vnd.github+json", + } + + def get_installation(self, installation_id: int) -> Optional[Dict[str, Any]]: + """Fetch installation metadata (account login/type).""" + try: + url = f"{self.api_url}/app/installations/{installation_id}" + resp = requests.get(url, headers=self._app_headers(), timeout=30) + if resp.status_code != 200: + print(f"Failed to fetch installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + return resp.json() + except Exception as e: + print(f"Error fetching installation {installation_id}: {e}") + return None + + def get_installation_access_token(self, installation_id: int) -> Optional[str]: + """Create a short-lived installation access token.""" + try: + url = f"{self.api_url}/app/installations/{installation_id}/access_tokens" + resp = requests.post(url, headers=self._app_headers(), json={}, timeout=30) + if resp.status_code != 201: + print(f"Failed to create access token for installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + data = resp.json() + return data.get("token") + except Exception as e: + print(f"Error creating access token for installation {installation_id}: {e}") + return None + diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index 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..41a3f03 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 @@ -200,4 +270,4 @@ async def _update_channels_for_guild(self, guild: discord.Guild, metrics: Dict[s except Exception as e: print(f"Error updating channels for guild {guild.name}: {e}") import traceback - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/discord_bot/src/services/notification_service.py b/discord_bot/src/services/notification_service.py index 96dd30d..b27da33 100644 --- a/discord_bot/src/services/notification_service.py +++ b/discord_bot/src/services/notification_service.py @@ -180,13 +180,13 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, """Build Discord embed for CI/CD notification.""" # Status-based configuration status_config = { - 'success': {'color': 0x28a745, 'emoji': 'βœ…', 'title': 'Workflow Completed'}, - 'failure': {'color': 0xdc3545, 'emoji': '❌', 'title': 'Workflow Failed'}, - 'in_progress': {'color': 0xffc107, 'emoji': 'πŸ”„', 'title': 'Workflow Running'}, - 'cancelled': {'color': 0x6c757d, 'emoji': '⏹️', 'title': 'Workflow Cancelled'} + 'success': {'color': 0x28a745, 'emoji': '', 'title': 'Workflow Completed'}, + 'failure': {'color': 0xdc3545, 'emoji': '', 'title': 'Workflow Failed'}, + 'in_progress': {'color': 0xffc107, 'emoji': '', 'title': 'Workflow Running'}, + 'cancelled': {'color': 0x6c757d, 'emoji': '️', 'title': 'Workflow Cancelled'} } - config = status_config.get(status, {'color': 0x6c757d, 'emoji': '❓', 'title': 'Workflow Status'}) + config = status_config.get(status, {'color': 0x6c757d, 'emoji': '', 'title': 'Workflow Status'}) embed = { "title": f"{config['emoji']} {config['title']}", @@ -217,7 +217,7 @@ def _build_cicd_embed(self, repo: str, workflow_name: str, status: str, async def _get_webhook_url(self, notification_type: str) -> Optional[str]: """Get webhook URL for specified notification type.""" try: - webhook_config = get_document('notification_config', 'webhooks') + webhook_config = get_document('global_config', 'ci_cd_webhooks') if not webhook_config: return None @@ -255,11 +255,11 @@ class WebhookManager: def set_webhook_url(notification_type: str, webhook_url: str) -> bool: """Set webhook URL for specified notification type.""" try: - webhook_config = get_document('notification_config', 'webhooks') or {} + webhook_config = get_document('global_config', 'ci_cd_webhooks') or {} webhook_config[f'{notification_type}_webhook_url'] = webhook_url webhook_config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'webhooks', webhook_config) + return set_document('global_config', 'ci_cd_webhooks', webhook_config) except Exception as e: logger.error(f"Failed to set webhook URL: {e}") return False @@ -268,7 +268,7 @@ def set_webhook_url(notification_type: str, webhook_url: str) -> bool: def get_monitored_repositories() -> List[str]: """Get list of repositories being monitored for CI/CD notifications.""" try: - config = get_document('notification_config', 'monitored_repos') + config = get_document('global_config', 'monitored_repositories') if not config: return [] return config.get('repositories', []) @@ -280,7 +280,7 @@ def get_monitored_repositories() -> List[str]: def add_monitored_repository(repo: str) -> bool: """Add repository to CI/CD monitoring list.""" try: - config = get_document('notification_config', 'monitored_repos') or {'repositories': []} + config = get_document('global_config', 'monitored_repositories') or {'repositories': []} repos = config.get('repositories', []) if repo not in repos: @@ -288,7 +288,7 @@ def add_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'monitored_repos', config) + return set_document('global_config', 'monitored_repositories', config) return True # Already exists except Exception as e: logger.error(f"Failed to add monitored repository: {e}") @@ -298,7 +298,7 @@ def add_monitored_repository(repo: str) -> bool: def remove_monitored_repository(repo: str) -> bool: """Remove repository from CI/CD monitoring list.""" try: - config = get_document('notification_config', 'monitored_repos') + config = get_document('global_config', 'monitored_repositories') if not config: return False @@ -308,7 +308,7 @@ def remove_monitored_repository(repo: str) -> bool: config['repositories'] = repos config['last_updated'] = datetime.utcnow().isoformat() - return set_document('notification_config', 'monitored_repos', config) + return set_document('global_config', 'monitored_repositories', config) return True # Already removed except Exception as e: logger.error(f"Failed to remove monitored repository: {e}") diff --git a/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..62e5513 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -36,8 +36,9 @@ 'description': 'Discord bot token for authentication' }, 'GITHUB_TOKEN': { - 'required': True, - 'description': 'GitHub personal access token for API access' + 'required': False, + 'warning_if_empty': 'GITHUB_TOKEN is optional when using a GitHub App; required only for legacy PAT-based features like workflow dispatch.', + 'description': 'GitHub personal access token for legacy API access' }, 'GITHUB_CLIENT_ID': { 'required': True, @@ -55,6 +56,25 @@ 'required': False, 'warning_if_empty': "OAUTH_BASE_URL is empty - if you're deploying to get an initial URL, this is OK. You can update it later after deployment.", 'description': 'Base URL for OAuth redirects (auto-detected on Cloud Run if empty)' + }, + 'DISCORD_BOT_CLIENT_ID': { + 'required': True, + 'description': 'Discord application ID (client ID)' + }, + 'GITHUB_APP_ID': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_ID is optional for legacy OAuth/PAT mode; required for the invite-only GitHub App installation flow.', + 'description': 'GitHub App ID (for GitHub App auth)' + }, + 'GITHUB_APP_PRIVATE_KEY_B64': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_PRIVATE_KEY_B64 is required for GitHub App auth unless GITHUB_APP_PRIVATE_KEY is provided.', + 'description': 'Base64-encoded GitHub App private key PEM' + }, + 'GITHUB_APP_SLUG': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_SLUG is required to generate the GitHub App install URL in /setup.', + 'description': 'GitHub App slug (the /apps/ part)' } } @@ -422,4 +442,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() 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/utils/reviewer_assigner.py b/pr_review/utils/reviewer_assigner.py index 8b8703e..f08131f 100644 --- a/pr_review/utils/reviewer_assigner.py +++ b/pr_review/utils/reviewer_assigner.py @@ -21,8 +21,8 @@ def __init__(self, config_path: Optional[str] = None): def _load_reviewers(self) -> List[str]: """Load reviewer pool from Firestore configuration.""" try: - logger.info("REVIEWER DEBUG: Attempting to load reviewers from pr_config/reviewers") - reviewer_data = get_document('pr_config', 'reviewers') + logger.info("REVIEWER DEBUG: Attempting to load reviewers from global_config/reviewer_pool") + reviewer_data = get_document('global_config', 'reviewer_pool') if reviewer_data and 'reviewers' in reviewer_data: reviewers = reviewer_data['reviewers'] @@ -104,7 +104,7 @@ def save_config(self): 'count': len(self.reviewers), 'last_updated': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()) } - success = set_document('pr_config', 'reviewers', reviewer_data) + success = set_document('global_config', 'reviewer_pool', reviewer_data) if success: logger.info(f"Saved {len(self.reviewers)} reviewers to Firestore") else: diff --git a/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..f986beb 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,150 @@ 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) -> Optional[Dict[str, Any]]: + """Get a document from Firestore with explicit collection routing.""" + mt_client = get_mt_client() + + if collection in ORG_SCOPED_COLLECTIONS: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + 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) -> bool: + """Set a document in Firestore with explicit collection routing.""" + mt_client = get_mt_client() + + if collection in ORG_SCOPED_COLLECTIONS: + if not discord_server_id: + raise ValueError(f"discord_server_id required for org-scoped collection: {collection}") + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + raise ValueError(f"No GitHub org found for Discord server: {discord_server_id}") + 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}