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

2) Configure custom roles:

/configure roles
{% if sync_triggered %} -

✅ Initial sync started. Stats will appear shortly.

+

Initial sync started. Stats will appear shortly.

{% else %} -

⏳ Initial sync will run on the next scheduled pipeline.

+

Initial sync will run on the next scheduled pipeline.

{% endif %}

3) Try these commands:

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

Add DisgitBot to Discord

-

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

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

Add DisgitBot

+

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

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

Features:

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

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

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

GitHub Connected!

-

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

- {% if is_personal_install %} -

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

+
+ +

Success!

+
+

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

+
+ +
+ +
+

Next Steps in Discord

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

Next Steps in Discord

-

1) Users link their GitHub accounts:

-
/link
-

2) Configure custom roles:

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

Initial sync started. Stats will appear shortly.

- {% else %} -

Initial sync will run on the next scheduled pipeline.

- {% endif %} -

3) Try these commands:

-
/getstats
-
/halloffame
@@ -690,53 +824,143 @@ def setup(): DisgitBot Setup + + +
-

DisgitBot Added Successfully!

-

Bot has been added to {{ guild_name }}

- -

Recommended: Install the GitHub App

-

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

- Install GitHub App +
+

DisgitBot Added!

+

Bot has been successfully added to {{ guild_name }}

+
-

Manual Setup (disabled)

-

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

+
-

+

+

Install the GitHub App

+

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

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

Setup Complete!

-

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

- -

Next Steps:

-

1. Return to Discord

-

2. Users can link their GitHub accounts with:

-
/link
- -

3. Try these commands:

-
/getstats
-
/halloffame
+
+
+ +

Success!

+
+

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

+
+ +
+ +
+

Next Steps in Discord

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

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

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

Setup Requested!

+

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

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

{{ title }}

+

{{ subtitle|safe }}

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

Authentication Successful!

-

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

-

You can now close this tab and return to Discord.

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

Setup Requested!

-

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

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