diff --git a/.github/workflows/discord_bot_pipeline.yml b/.github/workflows/discord_bot_pipeline.yml index 24e8e1c..9da3440 100644 --- a/.github/workflows/discord_bot_pipeline.yml +++ b/.github/workflows/discord_bot_pipeline.yml @@ -55,7 +55,8 @@ jobs: - name: Collect GitHub Data for Multiple Organizations env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_APP_ID: ${{ secrets.GH_APP_ID }} + GITHUB_APP_PRIVATE_KEY_B64: ${{ secrets.GH_APP_PRIVATE_KEY_B64 }} PYTHONUNBUFFERED: 1 PYTHONPATH: ${{ github.workspace }}/discord_bot:${{ github.workspace }} run: | @@ -93,6 +94,7 @@ jobs: sys.path.insert(0, 'src') from services.github_service import GitHubService + from services.github_app_service import GitHubAppService print('Getting registered organizations...') mt_client = get_mt_client() @@ -109,24 +111,30 @@ jobs: print(f' Server ID: {server_id}') print(f' Data: {server_data}') - # Extract unique GitHub organizations - github_orgs = set() + # Extract unique GitHub installations (preferred) with a stable org key + installations = {} for server_id, server_config in servers.items(): + installation_id = server_config.get('github_installation_id') github_org = server_config.get('github_org') - if github_org: - github_orgs.add(github_org) - print(f'Found GitHub org: {github_org} from server {server_id}') + if installation_id and github_org: + installations[int(installation_id)] = github_org + print(f'Found installation: {installation_id} for {github_org} (server {server_id})') else: - print(f'No github_org found in server {server_id}') + print(f'Skipping server {server_id}: missing github_installation_id or github_org') print(f'Available keys: {list(server_config.keys())}') + + print(f'Found {len(installations)} unique installations: {installations}') - print(f'Found {len(github_orgs)} unique organizations: {github_orgs}') - - # Collect data for each organization + # Collect data for each installation (GitHub App token) all_org_data = {} - for github_org in github_orgs: - print(f'Collecting data for organization: {github_org}') - github_service = GitHubService(github_org) + gh_app = GitHubAppService() + for installation_id, github_org in installations.items(): + print(f'Collecting data for installation {installation_id} ({github_org})') + token = gh_app.get_installation_access_token(installation_id) + if not token: + print(f'Failed to get installation token for {installation_id}, skipping') + continue + github_service = GitHubService(github_org, token=token, installation_id=installation_id) raw_data = github_service.collect_organization_data() all_org_data[github_org] = raw_data print(f'Collected data for {len(raw_data.get(\"repositories\", {}))} repositories in {github_org}') @@ -244,20 +252,22 @@ jobs: print(f'Stored labels for {labels_stored} repositories in {github_org}') - # Update user contribution data - user_mappings = {} - for doc in mt_client.db.collection('discord_users').stream(): - user_mappings[doc.id] = doc.to_dict() + # Update org-scoped user contribution data (per Discord server/org) + user_mappings = {doc.id: doc.to_dict() for doc in mt_client.db.collection('discord_users').stream()} stored_count = 0 - for username, user_data in contributions.items(): - # Find Discord users with this GitHub username - for discord_id, user_mapping in user_mappings.items(): - if user_mapping.get('github_id') == username: - if mt_client.set_user_mapping(discord_id, {**user_mapping, **user_data}): - stored_count += 1 + for discord_id, user_mapping in user_mappings.items(): + github_id = user_mapping.get('github_id') + if not github_id: + continue + user_data = contributions.get(github_id) + if not user_data: + continue + org_user_data = {**user_mapping, **user_data} + if mt_client.set_org_document(github_org, 'discord_users', discord_id, org_user_data): + stored_count += 1 - print(f'Updated contribution data for {stored_count} users in {github_org}') + print(f'Updated org-scoped contribution data for {stored_count} users in {github_org}') print('All organization data stored successfully!') " diff --git a/discord_bot/README.md b/discord_bot/README.md index 2d6e39c..9fb21e4 100644 --- a/discord_bot/README.md +++ b/discord_bot/README.md @@ -1,5 +1,22 @@ # Discord Bot Setup Guide +# Quick Start (Hosted Bot Users) + +Use this section if you only want to invite the hosted bot and use it in your Discord server. + +1. **Invite the bot** using the link provided by the maintainers. +2. In your Discord server, run: `/setup` +3. Click **Install GitHub App** and select the org/repo(s) to track. +4. Each user links their GitHub account with: `/link` +5. (Optional) Configure role rules: + ``` + /configure roles action:add metric:commits threshold:1 role:@Contributor + /configure roles action:add metric:prs threshold:10 role:@ActiveContributor + /configure roles action:add metric:prs threshold:50 role:@CoreTeam + ``` + +That’s it. No local setup, no tokens, no config files. + # 1. Prerequisites ### Python 3.13 Setup @@ -75,6 +92,15 @@ python -m pip install --upgrade pip pip install -r discord_bot/requirements.txt ``` +### Install `fzf` (interactive selector) + +The deployment helper uses [`fzf`](https://github.com/junegunn/fzf) for project/region menus. Install it before running any deployment scripts (and ensure `fzf` is on your `PATH`): + +- **macOS:** `brew install fzf` +- **Windows:** `choco install fzf` (Chocolatey) or `winget install fzf` +- **Ubuntu/Debian:** `sudo apt install fzf` +- **Fedora:** `sudo dnf install fzf` + # 2. Project Structure ``` @@ -108,6 +134,9 @@ cp discord_bot/config/.env.example discord_bot/config/.env - `GITHUB_TOKEN=` (GitHub API access) - `GITHUB_CLIENT_ID=` (GitHub OAuth app ID) - `GITHUB_CLIENT_SECRET=` (GitHub OAuth app secret) +- `GITHUB_APP_ID=` (GitHub App ID) +- `GITHUB_APP_PRIVATE_KEY_B64=` (GitHub App private key, base64) +- `GITHUB_APP_SLUG=` (GitHub App slug) - `REPO_OWNER=` (Your GitHub organization name) - `OAUTH_BASE_URL=` (Your Cloud Run URL - set in Step 4) @@ -121,6 +150,8 @@ Go to your GitHub repository → Settings → Secrets and variables → Actions - `GOOGLE_CREDENTIALS_JSON` - `REPO_OWNER` - `CLOUD_RUN_URL` +- `GH_APP_ID` +- `GH_APP_PRIVATE_KEY_B64` If you plan to run GitHub Actions from branches other than `main`, also add the matching development secrets so the workflows can deploy correctly: - `DEV_GOOGLE_CREDENTIALS_JSON` @@ -251,7 +282,7 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **What this does:** Creates a placeholder Cloud Run service to get your stable URL, which you'll need for GitHub OAuth setup. -1. **Run the URL getter script:** +1. **Run the URL getter script** (requires `fzf`, see prerequisites): ```bash ./discord_bot/deployment/get_url.sh ``` @@ -300,12 +331,47 @@ If you plan to run GitHub Actions from branches other than `main`, also add the **Example URLs:** If your Cloud Run URL is `https://discord-bot-abcd1234-uc.a.run.app`, then: - Homepage URL: `https://discord-bot-abcd1234-uc.a.run.app` - Callback URL: `https://discord-bot-abcd1234-uc.a.run.app/login/github/authorized` + - If you are using the newer hosted flow, set the callback to `YOUR_CLOUD_RUN_URL/auth/callback` instead. 4. **Get Credentials:** - Click "Register application" - Copy the "Client ID" → **Add to `.env`:** `GITHUB_CLIENT_ID=your_client_id` - Click "Generate a new client secret" → Copy it → **Add to `.env`:** `GITHUB_CLIENT_SECRET=your_secret` +### Step 5b: Create GitHub App (GITHUB_APP_ID / PRIVATE_KEY / SLUG) + +**What this configures:** +- `.env` file: `GITHUB_APP_ID=...`, `GITHUB_APP_PRIVATE_KEY_B64=...`, `GITHUB_APP_SLUG=...` +- GitHub Secrets: `GH_APP_ID`, `GH_APP_PRIVATE_KEY_B64` + +**What this does:** Allows DisgitBot to read repository data without user PATs. + +**Where these values come from:** +- `GITHUB_APP_ID`: shown on the GitHub App settings page (App ID field). +- `GITHUB_APP_PRIVATE_KEY_B64`: base64 of the downloaded `.pem` private key. +- `GITHUB_APP_SLUG`: the URL slug of your GitHub App (shown in the app page URL). + +1. **Create the GitHub App (org or personal):** + - For org: `https://github.com/organizations//settings/apps` + - For personal: `https://github.com/settings/apps` +2. **Set these URLs:** + - **Homepage URL:** `YOUR_CLOUD_RUN_URL` + - **Setup URL:** `YOUR_CLOUD_RUN_URL/github/app/setup` + - **Callback URL:** leave empty +3. **Permissions (read-only):** + - Metadata (required), Contents, Issues, Pull requests + - Webhooks: OFF +4. **Install target:** choose **Any account** so anyone can install it. +5. **Generate a private key:** + - Download the `.pem` file + - Base64 it (keep BEGIN/END lines): `base64 -w 0 path/to/private-key.pem` +6. **Set `.env` values:** + - `GITHUB_APP_ID=...` (App ID from the GitHub App page) + - `GITHUB_APP_PRIVATE_KEY_B64=...` (base64 from step 5) + - `GITHUB_APP_SLUG=...` (the app slug shown in the app page URL) + +**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:** diff --git a/discord_bot/config/.env.example b/discord_bot/config/.env.example index 28ee8c6..ebf50d6 100644 --- a/discord_bot/config/.env.example +++ b/discord_bot/config/.env.example @@ -4,4 +4,7 @@ GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= REPO_OWNER= OAUTH_BASE_URL= -DISCORD_BOT_CLIENT_ID= \ No newline at end of file +DISCORD_BOT_CLIENT_ID= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY_B64= +GITHUB_APP_SLUG= diff --git a/discord_bot/deployment/deploy.sh b/discord_bot/deployment/deploy.sh index 9ef76de..96842f7 100755 --- a/discord_bot/deployment/deploy.sh +++ b/discord_bot/deployment/deploy.sh @@ -12,6 +12,11 @@ PURPLE='\033[0;35m' CYAN='\033[0;36m' NC='\033[0m' # No Color +FZF_AVAILABLE=0 +if command -v fzf &>/dev/null; then + FZF_AVAILABLE=1 +fi + # Helper functions print_header() { echo -e "\n${PURPLE}================================${NC}" @@ -43,6 +48,12 @@ ENV_PATH="$ROOT_DIR/config/.env" print_header +if [ "$FZF_AVAILABLE" -eq 1 ]; then + print_success "fzf detected: you can type to filter options in selection menus." +else + print_warning "fzf not detected; falling back to arrow-key menu navigation." +fi + # Check if gcloud is installed and authenticated print_step "Checking Google Cloud CLI..." if ! command -v gcloud &> /dev/null; then @@ -132,6 +143,31 @@ interactive_select() { done } +fuzzy_select_or_fallback() { + local prompt="$1" + shift + local options=("$@") + + if [ "$FZF_AVAILABLE" -eq 1 ]; then + local selection + selection=$(printf '%s\n' "${options[@]}" | fzf --prompt="$prompt> " --height=15 --border --exit-0) + if [ -z "$selection" ]; then + print_warning "Selection cancelled." + exit 0 + fi + for i in "${!options[@]}"; do + if [[ "${options[$i]}" == "$selection" ]]; then + INTERACTIVE_SELECTION=$i + return + fi + done + print_error "Unable to match selection." + exit 1 + else + interactive_select "$prompt" "${options[@]}" + fi +} + # Function to select Google Cloud Project select_project() { print_step "Fetching your Google Cloud projects..." @@ -156,7 +192,7 @@ select_project() { done <<< "$projects" # Interactive selection - interactive_select "Select a Google Cloud Project:" "${project_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Project" "${project_options[@]}" selection=$INTERACTIVE_SELECTION PROJECT_ID="${project_ids[$selection]}" @@ -297,14 +333,8 @@ create_new_env_file() { print_warning "Discord Bot Token is required!" done - # GitHub Token - while true; do - read -p "GitHub Token: " github_token - if [ -n "$github_token" ]; then - break - fi - print_warning "GitHub Token is required!" - done + # GitHub Token (optional for GitHub App mode) + read -p "GitHub Token (optional): " github_token # GitHub Client ID read -p "GitHub Client ID: " github_client_id @@ -317,6 +347,14 @@ create_new_env_file() { # OAuth Base URL (optional - will auto-detect on Cloud Run) read -p "OAuth Base URL (optional): " oauth_base_url + + # Discord Bot Client ID + read -p "Discord Bot Client ID: " discord_bot_client_id + + # GitHub App configuration (invite-only mode) + read -p "GitHub App ID: " github_app_id + read -p "GitHub App Private Key (base64): " github_app_private_key_b64 + read -p "GitHub App Slug: " github_app_slug # Create .env file cat > "$ENV_PATH" << EOF @@ -326,6 +364,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file created successfully!" @@ -355,6 +397,18 @@ edit_env_file() { read -p "OAuth Base URL [$OAUTH_BASE_URL]: " new_oauth_base_url oauth_base_url=${new_oauth_base_url:-$OAUTH_BASE_URL} + + read -p "Discord Bot Client ID [$DISCORD_BOT_CLIENT_ID]: " new_discord_bot_client_id + discord_bot_client_id=${new_discord_bot_client_id:-$DISCORD_BOT_CLIENT_ID} + + read -p "GitHub App ID [$GITHUB_APP_ID]: " new_github_app_id + github_app_id=${new_github_app_id:-$GITHUB_APP_ID} + + read -p "GitHub App Private Key (base64) [$GITHUB_APP_PRIVATE_KEY_B64]: " new_github_app_private_key_b64 + github_app_private_key_b64=${new_github_app_private_key_b64:-$GITHUB_APP_PRIVATE_KEY_B64} + + read -p "GitHub App Slug [$GITHUB_APP_SLUG]: " new_github_app_slug + github_app_slug=${new_github_app_slug:-$GITHUB_APP_SLUG} # Update .env file cat > "$ENV_PATH" << EOF @@ -364,6 +418,10 @@ GITHUB_CLIENT_ID=$github_client_id GITHUB_CLIENT_SECRET=$github_client_secret REPO_OWNER=$repo_owner OAUTH_BASE_URL=$oauth_base_url +DISCORD_BOT_CLIENT_ID=$discord_bot_client_id +GITHUB_APP_ID=$github_app_id +GITHUB_APP_PRIVATE_KEY_B64=$github_app_private_key_b64 +GITHUB_APP_SLUG=$github_app_slug EOF print_success ".env file updated successfully!" @@ -469,7 +527,7 @@ get_deployment_config() { "custom" ) - interactive_select "Select a Google Cloud Region:" "${region_options[@]}" + fuzzy_select_or_fallback "Select a Google Cloud Region" "${region_options[@]}" region_choice=$INTERACTIVE_SELECTION if [ $region_choice -eq 5 ]; then # Custom region @@ -489,7 +547,7 @@ get_deployment_config() { declare -a memory_values=("512Mi" "1Gi" "2Gi" "custom") declare -a cpu_values=("1" "1" "2" "custom") - interactive_select "Select Resource Configuration:" "${resource_options[@]}" + fuzzy_select_or_fallback "Select Resource Configuration" "${resource_options[@]}" resource_choice=$INTERACTIVE_SELECTION if [ $resource_choice -eq 3 ]; then # Custom @@ -737,4 +795,4 @@ main() { } # Run main function -main \ No newline at end of file +main diff --git a/discord_bot/deployment/get_url.sh b/discord_bot/deployment/get_url.sh index 3a7f56b..5f05be1 100755 --- a/discord_bot/deployment/get_url.sh +++ b/discord_bot/deployment/get_url.sh @@ -45,6 +45,13 @@ if ! command -v gcloud &> /dev/null; then exit 1 fi +# Ensure fzf is available for interactive selection +print_step "Checking fzf (interactive selector)..." +if ! command -v fzf &> /dev/null; then + print_error "fzf is required for this script's menus. Please install it (see README)." + exit 1 +fi + # Check authentication with better error handling print_step "Verifying Google Cloud authentication..." auth_account=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | head -n1) @@ -61,71 +68,27 @@ print_success "Google Cloud CLI is ready!" # Function to select from options with arrow keys interactive_select() { local prompt="$1" - local options=("${@:2}") - local selected=0 - local num_options=${#options[@]} - - # Function to display options - display_options() { - clear - echo -e "\n${PURPLE}================================${NC}" - echo -e "${PURPLE} Discord Bot URL Getter Tool ${NC}" - echo -e "${PURPLE}================================${NC}\n" - echo -e "${BLUE}$prompt${NC}" - echo -e "${YELLOW}Use ↑/↓ arrow keys to navigate, SPACE/ENTER to select, q to quit${NC}\n" - - for i in "${!options[@]}"; do - if [ $i -eq $selected ]; then - echo -e "${GREEN}${options[i]}${NC}" - else - echo -e " ${options[i]}" - fi - done - } - - display_options - - while true; do - # Read a single character - read -rsn1 key - - case $key in - # Arrow up - $'\x1b') - read -rsn2 key - case $key in - '[A') # Up arrow - ((selected--)) - if [ $selected -lt 0 ]; then - selected=$((num_options - 1)) - fi - display_options - ;; - '[B') # Down arrow - ((selected++)) - if [ $selected -ge $num_options ]; then - selected=0 - fi - display_options - ;; - esac - ;; - ' '|'') # Space or Enter - clear - echo -e "\n${GREEN}Selected: ${options[$selected]}${NC}\n" - # Use a global variable to store the selection - INTERACTIVE_SELECTION=$selected - return 0 - ;; - 'q'|'Q') # Quit - clear - print_warning "Selection cancelled." - exit 0 - ;; - esac + shift + local options=("$@") + + echo "$prompt" >&2 + selection=$(printf "%s\n" "${options[@]}" | fzf --prompt="> " --height=10 --border) + + if [ -z "$selection" ]; then + echo "Cancelled." + exit 1 + fi + + # Find index + for i in "${!options[@]}"; do + if [[ "${options[$i]}" == "$selection" ]]; then + INTERACTIVE_SELECTION=$i + return + fi done } + # Function to select Google Cloud Project select_project() { print_step "Fetching your Google Cloud projects..." @@ -274,4 +237,4 @@ main() { } # Run main function -main \ No newline at end of file +main diff --git a/discord_bot/requirements.txt b/discord_bot/requirements.txt index 82bac54..903042f 100644 --- a/discord_bot/requirements.txt +++ b/discord_bot/requirements.txt @@ -10,3 +10,4 @@ python-dateutil==2.8.2 Werkzeug==3.0.1 matplotlib>=3.9.2 numpy>=2.0.0 +PyJWT[crypto]==2.9.0 diff --git a/discord_bot/scripts/setup_wizard.py b/discord_bot/scripts/setup_wizard.py new file mode 100644 index 0000000..5a881ef --- /dev/null +++ b/discord_bot/scripts/setup_wizard.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Simple setup wizard for self-hosted DisgitBot. +Generates discord_bot/config/.env from .env.example and optionally copies credentials.json. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +FIELD_DESCRIPTIONS = { + "DISCORD_BOT_TOKEN": "Discord bot token", + "GITHUB_TOKEN": "GitHub personal access token (needs repo read + workflow if using Actions)", + "GITHUB_CLIENT_ID": "GitHub OAuth app client ID", + "GITHUB_CLIENT_SECRET": "GitHub OAuth app client secret", + "REPO_OWNER": "GitHub org/user that owns this repo (for workflow dispatch)", + "OAUTH_BASE_URL": "Public base URL (e.g. https://)", + "DISCORD_BOT_CLIENT_ID": "Discord application ID (client ID)", + "GITHUB_APP_ID": "GitHub App ID (invite-only mode)", + "GITHUB_APP_PRIVATE_KEY_B64": "GitHub App private key (base64 PEM, invite-only mode)", + "GITHUB_APP_SLUG": "GitHub App slug (apps/)", +} + +REQUIRED_KEYS = { + "DISCORD_BOT_TOKEN", + "GITHUB_TOKEN", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "REPO_OWNER", + "OAUTH_BASE_URL", + "DISCORD_BOT_CLIENT_ID", +} + + +def _parse_env(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + + values: dict[str, str] = {} + for line in path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def _prompt_value(key: str, existing: str) -> str: + description = FIELD_DESCRIPTIONS.get(key, "") + label = f"{key}" + if description: + label += f" ({description})" + if existing: + label += f" [current: {existing}]" + label += ": " + + value = input(label).strip() + if not value: + return existing + return value + + +def _write_env(example_path: Path, env_path: Path, values: dict[str, str]) -> None: + lines = [] + for line in example_path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#"): + lines.append(line) + continue + if "=" not in line: + lines.append(line) + continue + key, _ = line.split("=", 1) + key = key.strip() + lines.append(f"{key}={values.get(key, '')}") + + env_path.write_text("\n".join(lines) + "\n") + + +def _handle_credentials(config_dir: Path) -> None: + target_path = config_dir / "credentials.json" + if target_path.exists(): + print(f"Found existing credentials at {target_path}") + return + + input_path = input( + "Path to Firebase service account JSON (leave blank to skip): " + ).strip() + if not input_path: + print("Skipping credentials copy. You must add config/credentials.json before running the bot.") + return + + source_path = Path(input_path).expanduser() + if not source_path.exists(): + print(f"File not found: {source_path}") + print("Skipping credentials copy.") + return + + shutil.copy2(source_path, target_path) + print(f"Copied credentials to {target_path}") + + +def main() -> int: + base_dir = Path(__file__).resolve().parents[1] + config_dir = base_dir / "config" + example_path = config_dir / ".env.example" + env_path = config_dir / ".env" + + if not example_path.exists(): + print(f"Missing {example_path}") + return 1 + + config_dir.mkdir(parents=True, exist_ok=True) + + existing_values = _parse_env(env_path) + new_values = dict(existing_values) + + print("DisgitBot setup wizard\n") + + example_keys = [] + for line in example_path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip() + example_keys.append(key) + current = existing_values.get(key, "") + new_values[key] = _prompt_value(key, current) + + # Prompt for any known keys missing from .env.example + for key in FIELD_DESCRIPTIONS: + if key in example_keys: + continue + current = existing_values.get(key, "") + new_values[key] = _prompt_value(key, current) + + missing_required = [ + key for key in REQUIRED_KEYS if not new_values.get(key) + ] + if missing_required: + print("\nMissing required values:") + for key in sorted(missing_required): + print(f"- {key}") + print("\nYou can re-run this wizard after collecting the missing values.") + + _write_env(example_path, env_path, new_values) + print(f"\nWrote {env_path}") + + _handle_credentials(config_dir) + + print("\nNext steps:") + print("- Run: python main.py (from discord_bot/)\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/discord_bot/src/bot/auth.py b/discord_bot/src/bot/auth.py index fa7f243..8dac737 100644 --- a/discord_bot/src/bot/auth.py +++ b/discord_bot/src/bot/auth.py @@ -5,6 +5,7 @@ from flask_dance.contrib.github import make_github_blueprint, github from dotenv import load_dotenv from werkzeug.middleware.proxy_fix import ProxyFix +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired load_dotenv() @@ -34,9 +35,12 @@ def create_oauth_app(): github_blueprint = make_github_blueprint( client_id=os.getenv("GITHUB_CLIENT_ID"), client_secret=os.getenv("GITHUB_CLIENT_SECRET"), - redirect_url=f"{base_url}/auth/callback" + redirect_url=f"{base_url}/auth/callback", + scope="read:org" ) app.register_blueprint(github_blueprint, url_prefix="/login") + + state_serializer = URLSafeTimedSerializer(app.secret_key, salt="github-app-install") @app.route("/") def index(): @@ -46,7 +50,9 @@ def index(): "endpoints": { "invite_bot": "/invite", "setup": "/setup", - "github_auth": "/auth/start/" + "github_auth": "/auth/start/", + "github_app_install": "/github/app/install", + "github_app_setup_callback": "/github/app/setup" } }) @@ -94,8 +100,7 @@ def invite_bot(): f"client_id={bot_client_id}&" f"permissions={permissions}&" f"integration_type=0&" - f"scope=bot+applications.commands&" - f"redirect_uri={base_url}/setup" + f"scope=bot+applications.commands" ) # Enhanced landing page with clear instructions @@ -153,16 +158,16 @@ def invite_bot():
Step 1: Click "Add Bot to Discord" above
-
- Step 2: After adding the bot, visit this setup URL: -
{base_url}/setup
-
-
- Step 3: Enter your GitHub organization name (e.g. "your-org") -
-
+
+ Step 2: After adding the bot, visit this setup URL: +
{base_url}/setup
+
+
+ Step 3: Install the GitHub App and select repositories +
+
Step 4: Users can link GitHub accounts with /link in Discord -
+

Features:

@@ -202,6 +207,7 @@ def start_oauth(discord_user_id): # Store user ID in session for callback session['discord_user_id'] = discord_user_id + session['oauth_flow'] = 'link' print(f"Starting OAuth for Discord user: {discord_user_id}") @@ -214,13 +220,13 @@ def start_oauth(discord_user_id): @app.route("/auth/callback") def github_callback(): - """Handle GitHub OAuth callback - original working version""" + """Handle GitHub OAuth callback for user account linking.""" try: discord_user_id = session.get('discord_user_id') if not discord_user_id: return "Authentication failed: No Discord user session", 400 - + if not github.authorized: print("GitHub OAuth not authorized") with oauth_sessions_lock: @@ -229,8 +235,7 @@ def github_callback(): 'error': 'GitHub authorization failed' } return "GitHub authorization failed", 400 - - # Get GitHub user info + resp = github.get("/user") if not resp.ok: print(f"GitHub API call failed: {resp.status_code}") @@ -240,10 +245,10 @@ def github_callback(): 'error': 'Failed to fetch GitHub user info' } return "Failed to fetch GitHub user information", 400 - + github_user = resp.json() github_username = github_user.get("login") - + if not github_username: print("No GitHub username found") with oauth_sessions_lock: @@ -252,17 +257,19 @@ def github_callback(): 'error': 'No GitHub username found' } return "Failed to get GitHub username", 400 - - # Store successful result + with oauth_sessions_lock: oauth_sessions[discord_user_id] = { 'status': 'completed', 'github_username': github_username, 'github_user_data': github_user } - + + session.pop('oauth_flow', None) + session.pop('discord_user_id', None) + print(f"OAuth completed for {github_username} (Discord: {discord_user_id})") - + return f""" Authentication Successful @@ -279,23 +286,153 @@ def github_callback(): """ - + except Exception as e: print(f"Error in OAuth callback: {e}") return f"Authentication failed: {str(e)}", 500 - + + @app.route("/github/app/install") + def github_app_install(): + """Redirect server owners to install the DisgitBot GitHub App.""" + from flask import request + + guild_id = request.args.get('guild_id') + guild_name = request.args.get('guild_name', 'your server') + + if not guild_id: + return "Error: No Discord server information received", 400 + + app_slug = os.getenv("GITHUB_APP_SLUG") + if not app_slug: + return "Server configuration error: missing GITHUB_APP_SLUG", 500 + + state = state_serializer.dumps({'guild_id': str(guild_id), 'guild_name': guild_name}) + + install_url = f"https://github.com/apps/{app_slug}/installations/new?state={state}" + return redirect(install_url) + + @app.route("/github/app/setup") + def github_app_setup(): + """GitHub App 'Setup URL' callback: stores installation ID for a Discord server.""" + from flask import request, render_template_string + from shared.firestore import get_mt_client + from datetime import datetime + from src.services.github_app_service import GitHubAppService + + installation_id = request.args.get('installation_id') + state = request.args.get('state', '') + + if not installation_id or not state: + return "Missing installation_id or state", 400 + + try: + payload = state_serializer.loads(state, max_age=60 * 30) + except SignatureExpired: + return "Setup link expired. Please restart setup from Discord.", 400 + except BadSignature: + return "Invalid setup state. Please restart setup from Discord.", 400 + + guild_id = str(payload.get('guild_id', '')) + guild_name = payload.get('guild_name', 'your server') + if not guild_id: + return "Invalid setup state (missing guild_id). Please restart setup from Discord.", 400 + + gh_app = GitHubAppService() + installation = gh_app.get_installation(int(installation_id)) + if not installation: + return "Failed to fetch installation details from GitHub.", 500 + + account = installation.get('account') or {} + github_account = account.get('login') + github_account_type = account.get('type') + + github_org = github_account + is_personal_install = github_account_type == 'User' + + mt_client = get_mt_client() + success = mt_client.set_server_config(guild_id, { + 'github_org': github_org, + 'github_installation_id': int(installation_id), + 'github_account': github_account, + 'github_account_type': github_account_type, + 'setup_source': 'github_app', + 'created_at': datetime.now().isoformat(), + 'setup_completed': True + }) + + if not success: + return "Error: Failed to save configuration", 500 + + success_page = """ + + + + GitHub Connected! + + + + + +
+

GitHub Connected!

+

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

+ {% if is_personal_install %} +

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

+ {% endif %} + +

Next Steps in Discord

+

1) Users link their GitHub accounts:

+
/link
+

2) Configure custom roles:

+
/configure roles
+

3) Try these commands:

+
/getstats
+
/halloffame
+
+ + + """ + + return render_template_string( + success_page, + guild_name=guild_name, + github_org=github_org, + is_personal_install=is_personal_install + ) + @app.route("/setup") def setup(): """Setup page after Discord bot is added to server""" from flask import request, render_template_string - + from urllib.parse import urlencode + # Get Discord server info from OAuth callback guild_id = request.args.get('guild_id') guild_name = request.args.get('guild_name', 'your server') - + if not guild_id: return "Error: No Discord server information received", 400 - + + github_app_install_url = f"{base_url}/github/app/install?{urlencode({'guild_id': guild_id, 'guild_name': guild_name})}" + setup_page = """ @@ -304,57 +441,51 @@ def setup():

DisgitBot Added Successfully!

Bot has been added to {{ guild_name }}

- -
- - -
- - -
- Enter the GitHub organization name you want to track.
- This is the name that appears in GitHub URLs: github.com/your-org/repo-name -
-
- - -
- + +

Recommended: Install the GitHub App

+

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

+ Install GitHub App + +

Manual Setup (disabled)

+

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

+

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

@@ -362,8 +493,13 @@ def setup(): """ - - return render_template_string(setup_page, guild_id=guild_id, guild_name=guild_name) + + return render_template_string( + setup_page, + guild_id=guild_id, + guild_name=guild_name, + github_app_install_url=github_app_install_url + ) @app.route("/complete_setup", methods=["POST"]) def complete_setup(): @@ -373,7 +509,10 @@ def complete_setup(): from datetime import datetime guild_id = request.form.get('guild_id') - github_org = request.form.get('github_org', '').strip() + selected_org = request.form.get('github_org', '').strip() + manual_org = request.form.get('manual_org', '').strip() + github_org = manual_org or selected_org + setup_source = request.form.get('setup_source', 'manual').strip() or 'manual' if not guild_id or not github_org: return "Error: Missing required information", 400 @@ -387,6 +526,7 @@ def complete_setup(): mt_client = get_mt_client() success = mt_client.set_server_config(guild_id, { 'github_org': github_org, + 'setup_source': setup_source, 'created_at': datetime.now().isoformat(), 'setup_completed': True }) @@ -394,12 +534,6 @@ def complete_setup(): if not success: return "Error: Failed to save configuration", 500 - # Trigger initial data collection for this organization - try: - trigger_data_pipeline_for_org(github_org) - except Exception as e: - print(f"Warning: Failed to trigger initial data collection: {e}") - # Don't fail setup if pipeline trigger fails success_page = """ @@ -493,38 +627,3 @@ def wait_for_username(discord_user_id, max_wait_time=300): del oauth_sessions[discord_user_id] return None - -def trigger_data_pipeline_for_org(github_org): - """Trigger the GitHub Actions workflow to collect data for a specific organization.""" - import requests - - # GitHub API endpoint for triggering workflow_dispatch - repo_owner = os.getenv('REPO_OWNER', 'ruxailab') - repo_name = "disgitbot" - workflow_id = "discord_bot_pipeline.yml" - - url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/{workflow_id}/dispatches" - - headers = { - "Authorization": f"token {os.getenv('GITHUB_TOKEN')}", - "Accept": "application/vnd.github.v3+json" - } - - payload = { - "ref": "main", - "inputs": { - "organization": github_org - } - } - - try: - response = requests.post(url, headers=headers, json=payload) - if response.status_code == 204: - print(f"Successfully triggered data pipeline for {github_org}") - return True - else: - print(f"Failed to trigger pipeline for {github_org}. Status: {response.status_code}") - return False - except Exception as e: - print(f"Error triggering pipeline for {github_org}: {e}") - return False diff --git a/discord_bot/src/bot/bot.py b/discord_bot/src/bot/bot.py index bd8288e..d85da74 100644 --- a/discord_bot/src/bot/bot.py +++ b/discord_bot/src/bot/bot.py @@ -10,7 +10,7 @@ from discord.ext import commands from dotenv import load_dotenv -from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands +from .commands import UserCommands, AdminCommands, AnalyticsCommands, NotificationCommands, ConfigCommands class DiscordBot: """Main Discord bot class with modular command registration.""" @@ -76,7 +76,8 @@ async def on_guild_join(guild): if system_channel: base_url = os.getenv("OAUTH_BASE_URL") - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + from urllib.parse import urlencode + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""🎉 **DisgitBot Added Successfully!** @@ -84,8 +85,9 @@ async def on_guild_join(guild): **Quick Setup (30 seconds):** 1. Visit: {setup_url} -2. Enter your GitHub organization name +2. Install the GitHub App and select repositories 3. Use `/link` in Discord to connect GitHub accounts +4. Customize roles with `/configure roles` **Or use this command:** `/setup` @@ -124,7 +126,8 @@ async def notify_unconfigured_servers(): if system_channel: base_url = os.getenv("OAUTH_BASE_URL") - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + from urllib.parse import urlencode + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""⚠️ **DisgitBot Setup Required** @@ -132,8 +135,9 @@ async def notify_unconfigured_servers(): **Quick Setup (30 seconds):** 1. Visit: {setup_url} -2. Enter your GitHub organization name +2. Install the GitHub App and select repositories 3. Use `/link` in Discord to connect GitHub accounts +4. Customize roles with `/configure roles` **Or use this command:** `/setup` @@ -156,11 +160,13 @@ def _register_commands(self): admin_commands = AdminCommands(self.bot) analytics_commands = AnalyticsCommands(self.bot) notification_commands = NotificationCommands(self.bot) + config_commands = ConfigCommands(self.bot) user_commands.register_commands() admin_commands.register_commands() analytics_commands.register_commands() notification_commands.register_commands() + config_commands.register_commands() print("All command modules registered") @@ -172,4 +178,4 @@ def run(self): def create_bot(): """Factory function to create Discord bot instance.""" - return DiscordBot() \ No newline at end of file + return DiscordBot() diff --git a/discord_bot/src/bot/commands/__init__.py b/discord_bot/src/bot/commands/__init__.py index 497a507..393f1f8 100644 --- a/discord_bot/src/bot/commands/__init__.py +++ b/discord_bot/src/bot/commands/__init__.py @@ -8,5 +8,6 @@ from .admin_commands import AdminCommands from .analytics_commands import AnalyticsCommands from .notification_commands import NotificationCommands +from .config_commands import ConfigCommands -__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands'] \ No newline at end of file +__all__ = ['UserCommands', 'AdminCommands', 'AnalyticsCommands', 'NotificationCommands', 'ConfigCommands'] diff --git a/discord_bot/src/bot/commands/admin_commands.py b/discord_bot/src/bot/commands/admin_commands.py index bff09e0..11a99a5 100644 --- a/discord_bot/src/bot/commands/admin_commands.py +++ b/discord_bot/src/bot/commands/admin_commands.py @@ -53,7 +53,7 @@ async def check_permissions(interaction: discord.Interaction): def _setup_command(self): """Create the setup command for server configuration.""" - @app_commands.command(name="setup", description="Get setup link to configure GitHub organization") + @app_commands.command(name="setup", description="Get setup link to connect GitHub organization") async def setup(interaction: discord.Interaction): """Provides setup link for server administrators.""" await interaction.response.defer(ephemeral=True) @@ -67,23 +67,40 @@ async def setup(interaction: discord.Interaction): guild = interaction.guild assert guild is not None, "Command should only work in guilds" + # Check existing configuration + from shared.firestore import get_mt_client + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) or {} + if server_config.get('setup_completed'): + github_org = server_config.get('github_org', 'unknown') + await interaction.followup.send( + f"✅ This server is already configured.\n\n" + f"GitHub org/account: `{github_org}`\n" + f"Users can run `/link` to connect their accounts.\n" + f"Admins can adjust roles with `/configure roles`.", + ephemeral=True + ) + return + # Get the base URL from environment import os + from urllib.parse import urlencode base_url = os.getenv("OAUTH_BASE_URL") if not base_url: await interaction.followup.send("Bot configuration error - please contact support.", ephemeral=True) return - setup_url = f"{base_url}/setup?guild_id={guild.id}&guild_name={guild.name}" + setup_url = f"{base_url}/setup?{urlencode({'guild_id': guild.id, 'guild_name': guild.name})}" setup_message = f"""**🔧 DisgitBot Setup Required** -Your server needs to be configured to track a GitHub organization. +Your server needs to connect a GitHub organization. **Steps:** 1. Visit: {setup_url} -2. Enter your GitHub organization name (e.g. "your-org") +2. Install the GitHub App and select repositories 3. Users can then link accounts with `/link` +4. Configure roles with `/configure roles` **Current Status:** ❌ Not configured **After Setup:** ✅ Ready to track contributions @@ -300,4 +317,4 @@ async def list_reviewers(interaction: discord.Interaction): import traceback traceback.print_exc() - return list_reviewers \ No newline at end of file + return list_reviewers diff --git a/discord_bot/src/bot/commands/config_commands.py b/discord_bot/src/bot/commands/config_commands.py new file mode 100644 index 0000000..b5d1491 --- /dev/null +++ b/discord_bot/src/bot/commands/config_commands.py @@ -0,0 +1,171 @@ +""" +Configuration Commands Module + +Server configuration commands for role mappings and setup checks. +""" + +import discord +from discord import app_commands +from shared.firestore import get_mt_client + + +class ConfigCommands: + """Handles configuration commands for server administrators.""" + + def __init__(self, bot): + self.bot = bot + + def register_commands(self): + """Register configuration commands with the bot.""" + configure_group = app_commands.Group( + name="configure", + description="Configure DisgitBot settings for this server" + ) + + @configure_group.command( + name="roles", + description="Manage custom role mappings by contributions" + ) + @app_commands.describe( + action="Choose an action", + metric="Contribution type to map", + threshold="Minimum count required", + role="Discord role to grant" + ) + @app_commands.choices( + action=[ + app_commands.Choice(name="list", value="list"), + app_commands.Choice(name="add", value="add"), + app_commands.Choice(name="remove", value="remove"), + app_commands.Choice(name="reset", value="reset"), + ], + metric=[ + app_commands.Choice(name="prs", value="pr"), + app_commands.Choice(name="issues", value="issue"), + app_commands.Choice(name="commits", value="commit"), + ] + ) + async def configure_roles( + interaction: discord.Interaction, + action: app_commands.Choice[str], + metric: app_commands.Choice[str] | None = None, + threshold: int | None = None, + role: discord.Role | None = None + ): + await interaction.response.defer(ephemeral=True) + + if not interaction.user.guild_permissions.administrator: + await interaction.followup.send("Only server administrators can configure roles.", ephemeral=True) + return + + guild = interaction.guild + if not guild: + await interaction.followup.send("This command can only be used in a server.", ephemeral=True) + return + + mt_client = get_mt_client() + server_config = mt_client.get_server_config(str(guild.id)) or {} + if not server_config.get('setup_completed'): + await interaction.followup.send("Run `/setup` first to connect GitHub.", ephemeral=True) + return + + role_rules = server_config.get('role_rules') or { + 'pr': [], + 'issue': [], + 'commit': [] + } + + action_value = action.value + + if action_value == "list": + await interaction.followup.send(self._format_role_rules(role_rules), ephemeral=True) + return + + if action_value == "reset": + role_rules = {'pr': [], 'issue': [], 'commit': []} + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + await interaction.followup.send("Role rules reset to defaults.", ephemeral=True) + return + + if action_value == "add": + if not metric or threshold is None or not role: + await interaction.followup.send( + "Usage: `/configure roles action:add metric: threshold: role:@Role`", + ephemeral=True + ) + return + + if threshold <= 0: + await interaction.followup.send("Threshold must be a positive number.", ephemeral=True) + return + + metric_key = metric.value + rules = role_rules.get(metric_key, []) + + # Remove existing rule for this role to avoid duplicates + rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + + rules.append({ + 'threshold': int(threshold), + 'role_id': str(role.id), + 'role_name': role.name + }) + + rules = sorted(rules, key=lambda r: r.get('threshold', 0)) + role_rules[metric_key] = rules + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send( + f"Added rule: {metric.name} {threshold}+ -> @{role.name}", + ephemeral=True + ) + return + + if action_value == "remove": + if not role: + await interaction.followup.send( + "Usage: `/configure roles action:remove role:@Role`", + ephemeral=True + ) + return + + removed = False + for key in ('pr', 'issue', 'commit'): + rules = role_rules.get(key, []) + new_rules = [rule for rule in rules if str(rule.get('role_id')) != str(role.id)] + if len(new_rules) != len(rules): + removed = True + role_rules[key] = new_rules + + if not removed: + await interaction.followup.send("That role is not in your custom rules.", ephemeral=True) + return + + server_config['role_rules'] = role_rules + mt_client.set_server_config(str(guild.id), server_config) + + await interaction.followup.send(f"Removed custom rules for @{role.name}.", ephemeral=True) + return + + await interaction.followup.send("Unknown action. Use list, add, remove, or reset.", ephemeral=True) + + self.bot.tree.add_command(configure_group) + + def _format_role_rules(self, role_rules: dict) -> str: + sections = [] + for key, label in (('pr', 'PRs'), ('issue', 'Issues'), ('commit', 'Commits')): + rules = role_rules.get(key, []) + if not rules: + sections.append(f"{label}: (no custom rules)") + continue + lines = [f"{label}:"] + for rule in sorted(rules, key=lambda r: r.get('threshold', 0)): + threshold = rule.get('threshold', 0) + role_name = rule.get('role_name', 'Unknown') + lines.append(f" - {threshold}+ -> @{role_name}") + sections.append("\n".join(lines)) + + return "Custom role rules:\n" + "\n\n".join(sections) diff --git a/discord_bot/src/bot/commands/user_commands.py b/discord_bot/src/bot/commands/user_commands.py index 9d6c329..fd93763 100644 --- a/discord_bot/src/bot/commands/user_commands.py +++ b/discord_bot/src/bot/commands/user_commands.py @@ -22,10 +22,16 @@ def __init__(self, bot): async def _safe_defer(self, interaction): """Safely defer interaction with error handling.""" try: + if interaction.response.is_done(): + return await interaction.response.defer(ephemeral=True) except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass + except discord.errors.HTTPException as exc: + if exc.code == 40060: + return + raise async def _safe_followup(self, interaction, message, embed=False): """Safely send followup message with error handling.""" @@ -37,6 +43,10 @@ async def _safe_followup(self, interaction, message, embed=False): except discord.errors.InteractionResponded: # Interaction was already responded to, continue anyway pass + except discord.errors.HTTPException as exc: + if exc.code == 40060: + return + raise def register_commands(self): """Register all user commands with the bot.""" @@ -57,6 +67,25 @@ async def link(interaction: discord.Interaction): try: discord_user_id = str(interaction.user.id) + discord_server_id = str(interaction.guild.id) + mt_client = get_mt_client() + + existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} + existing_github = existing_user_data.get('github_id') + existing_servers = existing_user_data.get('servers', []) + + if existing_github: + if discord_server_id not in existing_servers: + existing_servers.append(discord_server_id) + existing_user_data['servers'] = existing_servers + mt_client.set_user_mapping(discord_user_id, existing_user_data) + + await self._safe_followup( + interaction, + f"✅ Already linked to GitHub user: `{existing_github}`\n" + f"Use `/unlink` to disconnect and relink." + ) + return oauth_url = get_github_username_for_user(discord_user_id) await self._safe_followup(interaction, f"Please complete GitHub authentication: {oauth_url}") @@ -66,12 +95,6 @@ async def link(interaction: discord.Interaction): ) if github_username: - discord_server_id = str(interaction.guild.id) - - # Get existing user data or create new - from shared.firestore import get_mt_client - mt_client = get_mt_client() - existing_user_data = mt_client.get_user_mapping(discord_user_id) or {} # Add this server to user's server list servers_list = existing_user_data.get('servers', []) @@ -92,10 +115,11 @@ async def link(interaction: discord.Interaction): mt_client.set_user_mapping(discord_user_id, user_data) - # Trigger the data pipeline to collect stats for the new user - await self._trigger_data_pipeline() - - await self._safe_followup(interaction, f"Successfully linked to GitHub user: `{github_username}`\n Your stats will be available within a few minutes!") + await self._safe_followup( + interaction, + f"Successfully linked to GitHub user: `{github_username}`\n" + f"Stats and roles update on the next sync cycle." + ) else: await self._safe_followup(interaction, "Authentication timed out or failed. Please try again.") @@ -114,17 +138,19 @@ async def unlink(interaction: discord.Interaction): try: await self._safe_defer(interaction) + discord_user_id = str(interaction.user.id) discord_server_id = str(interaction.guild.id) - user_data = get_document('discord_users', str(interaction.user.id), discord_server_id) - - if user_data: - # Delete document by setting it to empty (Firestore will remove it) - discord_server_id = str(interaction.guild.id) - set_document('discord_users', str(interaction.user.id), {}, discord_server_id=discord_server_id) - await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") - print(f"Unlinked Discord user {interaction.user.name}") - else: + mt_client = get_mt_client() + + user_mapping = mt_client.get_user_mapping(discord_user_id) or {} + if not user_mapping.get('github_id'): await self._safe_followup(interaction, "Your Discord account is not linked to any GitHub username.") + return + + mt_client.set_user_mapping(discord_user_id, {}) + set_document('discord_users', discord_user_id, {}, discord_server_id=discord_server_id) + await self._safe_followup(interaction, "Successfully unlinked your Discord account from your GitHub username.") + print(f"Unlinked Discord user {interaction.user.name}") except Exception as e: print(f"Error unlinking user: {e}") @@ -154,22 +180,17 @@ async def getstats(interaction: discord.Interaction, type: str = "pr"): user_id = str(interaction.user.id) - # Get user's Discord data to find their GitHub username + # Check global link mapping first discord_server_id = str(interaction.guild.id) - discord_user_data = get_document('discord_users', user_id, discord_server_id) - if not discord_user_data or not discord_user_data.get('github_id'): + mt_client = get_mt_client() + user_mapping = mt_client.get_user_mapping(user_id) or {} + github_username = user_mapping.get('github_id') + if not github_username: await self._safe_followup(interaction, "Your Discord account is not linked to a GitHub username. Use `/link` to link it.") return - github_username = discord_user_data['github_id'] - - # Use the Discord user data which should contain the full contribution stats - # The pipeline updates Discord documents with full contribution data - user_data = discord_user_data - - if not user_data: - await self._safe_followup(interaction, f"No contribution data found for GitHub user '{github_username}'.") - return + # Fetch org-scoped stats for this server + user_data = get_document('discord_users', user_id, discord_server_id) or {} # Get stats and create embed embed = await self._create_stats_embed(user_data, github_username, stats_type, interaction) @@ -351,36 +372,3 @@ def _create_halloffame_embed(self, top_3, type, period, last_updated): embed.set_footer(text=f"Last updated: {last_updated or 'Unknown'}") return embed - async def _trigger_data_pipeline(self): - """Trigger the GitHub Actions workflow to collect data for the new user.""" - import aiohttp - import os - - # GitHub API endpoint for triggering workflow_dispatch - repo_owner = os.getenv('REPO_OWNER', 'ruxailab') - repo_name = "disgitbot" - workflow_id = "discord_bot_pipeline.yml" - - url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/actions/workflows/{workflow_id}/dispatches" - - headers = { - "Authorization": f"token {os.getenv('GITHUB_TOKEN')}", - "Accept": "application/vnd.github.v3+json" - } - - payload = { - "ref": "main" - } - - try: - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=payload) as response: - if response.status == 204: - print("Successfully triggered data pipeline") - return True - else: - print(f"Failed to trigger pipeline. Status: {response.status}") - return False - except Exception as e: - print(f"Error triggering pipeline: {e}") - return False diff --git a/discord_bot/src/services/github_app_service.py b/discord_bot/src/services/github_app_service.py new file mode 100644 index 0000000..a9775cb --- /dev/null +++ b/discord_bot/src/services/github_app_service.py @@ -0,0 +1,89 @@ +import base64 +import os +import time +from typing import Any, Dict, Optional + +import requests + + +class GitHubAppService: + """GitHub App authentication helpers (JWT + installation access tokens).""" + + def __init__(self): + self.api_url = "https://api.github.com" + self.app_id = os.getenv("GITHUB_APP_ID") + self._private_key_pem = self._load_private_key_pem() + + self._jwt_token: Optional[str] = None + self._jwt_exp: int = 0 + + if not self.app_id: + raise ValueError("GITHUB_APP_ID environment variable is required for GitHub App auth") + if not self._private_key_pem: + raise ValueError("GITHUB_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY_B64) is required for GitHub App auth") + + def _load_private_key_pem(self) -> str: + key = os.getenv("GITHUB_APP_PRIVATE_KEY", "") + if key: + return key.replace("\\n", "\n") + + key_b64 = os.getenv("GITHUB_APP_PRIVATE_KEY_B64", "") + if key_b64: + return base64.b64decode(key_b64).decode("utf-8") + + return "" + + def get_app_jwt(self) -> str: + """Create (or reuse) an app JWT.""" + now = int(time.time()) + if self._jwt_token and now < (self._jwt_exp - 60): + return self._jwt_token + + try: + import jwt # PyJWT + except Exception as e: + raise RuntimeError("PyJWT is required for GitHub App auth. Install PyJWT[crypto].") from e + + payload = { + "iat": now - 60, + "exp": now + 9 * 60, + "iss": self.app_id, + } + token = jwt.encode(payload, self._private_key_pem, algorithm="RS256") + self._jwt_token = token + self._jwt_exp = payload["exp"] + return token + + def _app_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.get_app_jwt()}", + "Accept": "application/vnd.github+json", + } + + def get_installation(self, installation_id: int) -> Optional[Dict[str, Any]]: + """Fetch installation metadata (account login/type).""" + try: + url = f"{self.api_url}/app/installations/{installation_id}" + resp = requests.get(url, headers=self._app_headers(), timeout=30) + if resp.status_code != 200: + print(f"Failed to fetch installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + return resp.json() + except Exception as e: + print(f"Error fetching installation {installation_id}: {e}") + return None + + def get_installation_access_token(self, installation_id: int) -> Optional[str]: + """Create a short-lived installation access token.""" + try: + url = f"{self.api_url}/app/installations/{installation_id}/access_tokens" + resp = requests.post(url, headers=self._app_headers(), json={}, timeout=30) + if resp.status_code != 201: + print(f"Failed to create access token for installation {installation_id}: {resp.status_code} {resp.text[:200]}") + return None + data = resp.json() + return data.get("token") + except Exception as e: + print(f"Error creating access token for installation {installation_id}: {e}") + return None + diff --git a/discord_bot/src/services/github_service.py b/discord_bot/src/services/github_service.py index 453b2ca..2500211 100644 --- a/discord_bot/src/services/github_service.py +++ b/discord_bot/src/services/github_service.py @@ -13,18 +13,18 @@ class GitHubService: """GitHub API service for data collection.""" - def __init__(self, repo_owner: str = None): + def __init__(self, repo_owner: str = None, token: Optional[str] = None, installation_id: Optional[int] = None): self.api_url = "https://api.github.com" - self.token = os.getenv('GITHUB_TOKEN') + self.token = token or os.getenv('GITHUB_TOKEN') self.repo_owner = repo_owner or os.getenv('REPO_OWNER', 'ruxailab') - - if not self.token: - raise ValueError("GITHUB_TOKEN environment variable is required") + self.installation_id = installation_id self._request_count = 0 def _get_headers(self) -> Dict[str, str]: """Get GitHub API headers with authentication.""" + if not self.token: + raise ValueError("GitHub token is required for API access") return { "Authorization": f"token {self.token}", "Accept": "application/vnd.github.v3+json" @@ -193,7 +193,8 @@ def _paginate_list_results(self, base_url: str, rate_type: str = 'core') -> List print(f"DEBUG - Starting list pagination for: {base_url}") while True: - paginated_url = f"{base_url}?per_page={per_page}&page={page}" + joiner = "&" if "?" in base_url else "?" + paginated_url = f"{base_url}{joiner}per_page={per_page}&page={page}" response = self._make_request(paginated_url, rate_type) if not response or response.status_code != 200: @@ -237,6 +238,48 @@ def fetch_repository_labels(self, owner: str, repo: str) -> List[Dict[str, Any]] labels_url = f"{self.api_url}/repos/{owner}/{repo}/labels" return self._paginate_list_results(labels_url, 'core') + def fetch_installation_repositories(self) -> List[Dict[str, str]]: + """Fetch repositories available to the current installation token.""" + if not self.installation_id: + return [] + + try: + repos_url = f"{self.api_url}/installation/repositories" + all_repos: List[Dict[str, str]] = [] + page = 1 + per_page = 100 + + while True: + url = f"{repos_url}?per_page={per_page}&page={page}" + response = self._make_request(url, 'core') + + if not response or response.status_code != 200: + print(f"Failed to fetch installation repositories at page {page}") + break + + data = response.json() or {} + repos_data = data.get('repositories', []) or [] + if not repos_data: + break + + for repo in repos_data: + owner = (repo.get('owner') or {}).get('login') + name = repo.get('name') + if owner and name: + all_repos.append({'name': name, 'owner': owner}) + + total = data.get('total_count', len(all_repos)) + if len(repos_data) < per_page or len(all_repos) >= total: + break + + page += 1 + + print(f"Found {len(all_repos)} repositories for installation") + return all_repos + except Exception as e: + print(f"Error fetching installation repositories: {e}") + return [] + def fetch_organization_repositories(self) -> List[Dict[str, str]]: """Fetch all repositories for the organization.""" try: @@ -255,6 +298,14 @@ def fetch_organization_repositories(self) -> List[Dict[str, str]]: except Exception as e: print(f"Error fetching repositories: {e}") return [] + + def fetch_accessible_repositories(self) -> List[Dict[str, str]]: + """Fetch repositories accessible by this token (installation or org token).""" + if self.installation_id: + repos = self.fetch_installation_repositories() + if repos: + return repos + return self.fetch_organization_repositories() def search_pull_requests(self, owner: str, repo: str) -> Dict[str, Any]: """Search for ALL pull requests in a repository with complete pagination.""" @@ -316,7 +367,7 @@ def collect_complete_repository_data(self, owner: str, repo: str) -> Dict[str, A return repo_data def collect_organization_data(self) -> Dict[str, Any]: - """Collect complete data for all repositories in the organization.""" + """Collect complete data for all repositories accessible by this token.""" print("========== Collecting Organization Data ==========") # Validate GitHub token @@ -332,7 +383,7 @@ def collect_organization_data(self) -> Dict[str, Any]: print("WARNING: Unable to check initial rate limits") # Fetch all repositories - repos = self.fetch_organization_repositories() + repos = self.fetch_accessible_repositories() # Collect data for each repository all_data = { @@ -356,4 +407,4 @@ def collect_organization_data(self) -> Dict[str, Any]: all_data['total_api_requests'] = self._request_count print(f"DEBUG - Total API requests made: {self._request_count}") - return all_data \ No newline at end of file + return all_data diff --git a/discord_bot/src/services/guild_service.py b/discord_bot/src/services/guild_service.py index 2c73650..41a3f03 100644 --- a/discord_bot/src/services/guild_service.py +++ b/discord_bot/src/services/guild_service.py @@ -30,6 +30,7 @@ async def update_roles_and_channels(self, discord_server_id: str, user_mappings: mt_client = get_mt_client() server_config = mt_client.get_server_config(discord_server_id) github_org = server_config.get('github_org') if server_config else None + role_rules = server_config.get('role_rules') if server_config else {} success = False @@ -49,7 +50,13 @@ async def on_ready(): print(f"Processing guild: {guild.name} (ID: {guild.id})") # Update roles with organization-specific data - updated_count = await self._update_roles_for_guild(guild, user_mappings, contributions, github_org) + updated_count = await self._update_roles_for_guild( + guild, + user_mappings, + contributions, + github_org, + role_rules or {} + ) print(f"Updated {updated_count} members in {guild.name}") # Update channels @@ -78,7 +85,14 @@ async def on_ready(): traceback.print_exc() return False - async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dict[str, str], contributions: Dict[str, Any], github_org: str) -> int: + async def _update_roles_for_guild( + self, + guild: discord.Guild, + user_mappings: Dict[str, str], + contributions: Dict[str, Any], + github_org: str, + role_rules: Dict[str, Any] + ) -> int: """Update roles for a single guild using role service.""" if not self._role_service: print("Role service not available - skipping role updates") @@ -93,6 +107,22 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic obsolete_roles = self._role_service.get_obsolete_role_names() current_roles = set(self._role_service.get_all_role_names()) existing_roles = {role.name: role for role in guild.roles} + existing_roles_by_id = {role.id: role for role in guild.roles} + + custom_role_ids = set() + custom_role_names = set() + for rules in role_rules.values(): + if not isinstance(rules, list): + continue + for rule in rules: + role_id = str(rule.get('role_id', '')).strip() + role_name = str(rule.get('role_name', '')).strip() + if role_id.isdigit(): + custom_role_ids.add(int(role_id)) + if role_name: + custom_role_names.add(role_name) + + managed_role_names = current_roles | custom_role_names # Remove obsolete roles from server for role_name in obsolete_roles: @@ -119,6 +149,19 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic except Exception as e: print(f"Error creating role {role_name}: {e}") + def resolve_custom_role(rule: Dict[str, Any]): + if not rule: + return None + role_id = str(rule.get('role_id', '')).strip() + if role_id.isdigit(): + role_obj = existing_roles_by_id.get(int(role_id)) + if role_obj: + return role_obj + role_name = str(rule.get('role_name', '')).strip() + if role_name: + return existing_roles.get(role_name) + return None + # Update users updated_count = 0 for member in guild.members: @@ -133,26 +176,43 @@ async def _update_roles_for_guild(self, guild: discord.Guild, user_mappings: Dic # Get correct roles for user pr_role, issue_role, commit_role = self._role_service.determine_roles(pr_count, issues_count, commits_count) - correct_roles = {pr_role, issue_role, commit_role} + custom_roles = self._role_service.determine_custom_roles(pr_count, issues_count, commits_count, role_rules) + + pr_role_obj = resolve_custom_role(custom_roles.get('pr')) or existing_roles.get(pr_role) + issue_role_obj = resolve_custom_role(custom_roles.get('issue')) or existing_roles.get(issue_role) + commit_role_obj = resolve_custom_role(custom_roles.get('commit')) or existing_roles.get(commit_role) + + correct_role_objs = [] + for role_obj in (pr_role_obj, issue_role_obj, commit_role_obj): + if role_obj and role_obj not in correct_role_objs: + correct_role_objs.append(role_obj) + if github_username in medal_assignments: - correct_roles.add(medal_assignments[github_username]) - correct_roles.discard(None) - + medal_role_name = medal_assignments[github_username] + medal_role_obj = existing_roles.get(medal_role_name) + if medal_role_obj and medal_role_obj not in correct_role_objs: + correct_role_objs.append(medal_role_obj) + + correct_role_ids = {role.id for role in correct_role_objs} + # Remove obsolete roles and roles user outgrew - user_bot_roles = [role for role in member.roles if role.name in (obsolete_roles | current_roles)] - roles_to_remove = [role for role in user_bot_roles if role.name not in correct_roles] + user_bot_roles = [ + role for role in member.roles + if role.name in (obsolete_roles | managed_role_names) or role.id in custom_role_ids + ] + roles_to_remove = [role for role in user_bot_roles if role.id not in correct_role_ids] if roles_to_remove: await member.remove_roles(*roles_to_remove) print(f"Removed {[r.name for r in roles_to_remove]} from {member.name}") # Add missing roles - for role_name in correct_roles: - if role_name in roles and roles[role_name] not in member.roles: - await member.add_roles(roles[role_name]) - print(f"Added {role_name} to {member.name}") + for role_obj in correct_role_objs: + if role_obj not in member.roles: + await member.add_roles(role_obj) + print(f"Added {role_obj.name} to {member.name}") - if roles_to_remove or any(role_name in roles and roles[role_name] not in member.roles for role_name in correct_roles): + if roles_to_remove or any(role_obj not in member.roles for role_obj in correct_role_objs): updated_count += 1 return updated_count @@ -210,4 +270,4 @@ async def _update_channels_for_guild(self, guild: discord.Guild, metrics: Dict[s except Exception as e: print(f"Error updating channels for guild {guild.name}: {e}") import traceback - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/discord_bot/src/services/role_service.py b/discord_bot/src/services/role_service.py index ab43410..7f72ac7 100644 --- a/discord_bot/src/services/role_service.py +++ b/discord_bot/src/services/role_service.py @@ -107,6 +107,25 @@ def determine_roles(self, pr_count: int, issues_count: int, commits_count: int) commit_role = self._determine_role_for_threshold(commits_count, self.config.commit_thresholds) return pr_role, issue_role, commit_role + + def determine_custom_roles(self, pr_count: int, issues_count: int, commits_count: int, role_rules: Dict[str, Any]) -> Dict[str, Optional[Dict[str, Any]]]: + """Determine custom roles from per-server role rules.""" + return { + 'pr': self._select_custom_rule(pr_count, role_rules.get('pr', [])), + 'issue': self._select_custom_rule(issues_count, role_rules.get('issue', [])), + 'commit': self._select_custom_rule(commits_count, role_rules.get('commit', [])) + } + + def _select_custom_rule(self, count: int, rules: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Pick the highest-threshold custom rule that the count satisfies.""" + if not rules: + return None + sorted_rules = sorted(rules, key=lambda r: r.get('threshold', 0)) + selected = None + for rule in sorted_rules: + if count >= int(rule.get('threshold', 0)): + selected = rule + return selected def _determine_role_for_threshold(self, count: int, thresholds: Dict[str, int]) -> Optional[str]: """Determine role for a specific contribution type.""" @@ -180,4 +199,4 @@ def get_next_role(self, current_role: str, stats_type: str) -> str: next_role = role_list[i + 1][0] return f"@{next_role}" - return "Unknown" \ No newline at end of file + return "Unknown" diff --git a/discord_bot/src/utils/env_validator.py b/discord_bot/src/utils/env_validator.py index 6963535..62e5513 100644 --- a/discord_bot/src/utils/env_validator.py +++ b/discord_bot/src/utils/env_validator.py @@ -36,8 +36,9 @@ 'description': 'Discord bot token for authentication' }, 'GITHUB_TOKEN': { - 'required': True, - 'description': 'GitHub personal access token for API access' + 'required': False, + 'warning_if_empty': 'GITHUB_TOKEN is optional when using a GitHub App; required only for legacy PAT-based features like workflow dispatch.', + 'description': 'GitHub personal access token for legacy API access' }, 'GITHUB_CLIENT_ID': { 'required': True, @@ -55,6 +56,25 @@ 'required': False, 'warning_if_empty': "OAUTH_BASE_URL is empty - if you're deploying to get an initial URL, this is OK. You can update it later after deployment.", 'description': 'Base URL for OAuth redirects (auto-detected on Cloud Run if empty)' + }, + 'DISCORD_BOT_CLIENT_ID': { + 'required': True, + 'description': 'Discord application ID (client ID)' + }, + 'GITHUB_APP_ID': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_ID is optional for legacy OAuth/PAT mode; required for the invite-only GitHub App installation flow.', + 'description': 'GitHub App ID (for GitHub App auth)' + }, + 'GITHUB_APP_PRIVATE_KEY_B64': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_PRIVATE_KEY_B64 is required for GitHub App auth unless GITHUB_APP_PRIVATE_KEY is provided.', + 'description': 'Base64-encoded GitHub App private key PEM' + }, + 'GITHUB_APP_SLUG': { + 'required': False, + 'warning_if_empty': 'GITHUB_APP_SLUG is required to generate the GitHub App install URL in /setup.', + 'description': 'GitHub App slug (the /apps/ part)' } } @@ -422,4 +442,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/shared/firestore.py b/shared/firestore.py index a8dafc7..d94456f 100644 --- a/shared/firestore.py +++ b/shared/firestore.py @@ -227,6 +227,14 @@ def get_document(collection: str, document_id: str, discord_server_id: str = Non print(f"No GitHub org found for Discord server: {discord_server_id}") return None return mt_client.get_org_document(github_org, collection, document_id) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return None + return mt_client.get_org_document(github_org, collection, document_id) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -258,6 +266,14 @@ def set_document(collection: str, document_id: str, data: Dict[str, Any], merge: print(f"No GitHub org found for Discord server: {discord_server_id}") return False return mt_client.set_org_document(github_org, collection, document_id, data, merge) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.set_org_document(github_org, collection, document_id, data, merge) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -289,6 +305,14 @@ def update_document(collection: str, document_id: str, data: Dict[str, Any], dis print(f"No GitHub org found for Discord server: {discord_server_id}") return False return mt_client.update_org_document(github_org, collection, document_id, data) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return False + return mt_client.update_org_document(github_org, collection, document_id, data) # Handle user mappings (old 'discord' collection) if collection == 'discord': @@ -317,6 +341,14 @@ def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, print(f"No GitHub org found for Discord server: {discord_server_id}") return {} return mt_client.query_org_collection(github_org, collection, filters) + + # Handle org-scoped user stats + if collection == 'discord_users' and discord_server_id: + github_org = mt_client.get_org_from_server(discord_server_id) + if not github_org: + print(f"No GitHub org found for Discord server: {discord_server_id}") + return {} + return mt_client.query_org_collection(github_org, collection, filters) # Handle user mappings (old 'discord' collection) - return all users if collection == 'discord': @@ -345,4 +377,4 @@ def query_collection(collection: str, filters: Optional[Dict[str, Any]] = None, return {doc.id: doc.to_dict() for doc in docs} except Exception as e: print(f"Error querying collection {collection}: {e}") - return {} \ No newline at end of file + return {}