From 5986c59cc8e3775a2b21d6ae356950e81ae32ed2 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 11 Jun 2025 11:43:27 -0300 Subject: [PATCH 01/24] feat/IDP-43: inital code --- .gitignore | 46 + .pre-commit-config.yaml | 109 ++ .pre-commit-hooks.yaml | 14 + .yamllint.yaml | 35 + cpk_lib_python_github/README.md | 503 ++++++++ cpk_lib_python_github/__init__.py | 16 + .../__init__.py | 3 + .../github_app_token_generator/__init__.py | 17 + .../github_app_token_generator/__main__.py | 6 + .../github_app_token_generator/main.py | 1105 +++++++++++++++++ pyproject.toml | 87 ++ setup.py | 5 + 12 files changed, 1946 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .pre-commit-hooks.yaml create mode 100644 .yamllint.yaml create mode 100644 cpk_lib_python_github/README.md create mode 100644 cpk_lib_python_github/__init__.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/__init__.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__main__.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py create mode 100644 pyproject.toml create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4f4aef --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +env/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Development files +deleted +.python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8449bf0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,109 @@ +--- +repos: + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=500] + - id: trailing-whitespace + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-encoding-pragma + - id: file-contents-sorter + - id: check-case-conflict + - id: mixed-line-ending + args: [--fix=lf] + # ----------------------------- + # Checkov is a static code analysis tool for scanning infrastructure as code (IaC) files for misconfigurations + # that may lead to security or compliance problems. + # ----------------------------- + # Checkov includes more than 750 predefined policies to check for common misconfiguration issues. + # Checkov also supports the creation and contribution of custom policies. + # ----------------------------- + # - repo: https://github.com/bridgecrewio/checkov.git + # rev: 3.2.174 + # hooks: + # - id: checkov + + # ----------------------------- + # Python Code Formatting with Black + # ----------------------------- + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3 + files: \.py$ + args: [--config=pyproject.toml] + + # ----------------------------- + # Python Import Sorting with isort (complements Black) + # ----------------------------- + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + files: \.py$ + args: [--profile=black, --line-length=88] + + # ----------------------------- + # Python Code Quality with Pylint + # ----------------------------- + - repo: https://github.com/pycqa/pylint + rev: v3.3.7 + hooks: + - id: pylint + args: [--rcfile=pyproject.toml] + files: \.py$ + additional_dependencies: [PyJWT, requests, toml, colorama, setuptools] + + # ----------------------------- + # Gitleaks SAST tool for detecting and preventing hardcoded secrets like passwords, api keys, and tokens in git repos + # ----------------------------- + # If you are knowingly committing something that is not a secret and gitleaks is catching it, + # you can add an inline comment of '# gitleaks:allow' to the end of that line in your file. + # This will instructs gitleaks to ignore that secret - example: + # some_non_secret_value = a1b2c3d4e5f6g7h8i9j0 # gitleaks:allow + # ----------------------------- + - repo: https://github.com/gitleaks/gitleaks + rev: v8.27.2 + hooks: + - id: gitleaks + # ----------------------------- + # # Generates Table of Contents in Markdown files + # # ----------------------------- + - repo: https://github.com/frnmst/md-toc + rev: 9.0.0 + hooks: + - id: md-toc + args: [-p, github] # CLI options + # ----------------------------- + # YAML Linting on yaml files for pre-commit and github actions + # ----------------------------- + - repo: https://github.com/adrienverge/yamllint + rev: v1.37.1 + hooks: + - id: yamllint + name: Check YAML syntax with yamllint + args: [--strict, -c=.yamllint.yaml, '.'] + always_run: true + pass_filenames: true + + # ----------------------------- + # GitHub Actions Workflow Linting on .github/workflows/*.yml files + # ----------------------------- + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint + + - repo: local + hooks: + - id: toml build + name: test the .toml package health + entry: pip3 install . + language: python + pass_filenames: false + always_run: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..ac3d3b8 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,14 @@ +--- +- id: python-pypi-version-check + name: python-pypi-version-check + description: Check if Python package already exists on PYPI. + # entry: hooks/pypi_bumpversion_check-check + entry: python-pypi-version-check + language: python + +- id: find-and-replace-strings + name: find-and-replace-strings + description: Check if Python package already exists on PYPI. + entry: find-and-replace-strings + # entry: hooks/find_and_replace_strings + language: python diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..0ccdc36 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,35 @@ +--- +yaml-files: + - '*.yaml' + - '*.yml' + - '.yamllint' + +rules: + anchors: enable + braces: enable + brackets: enable + colons: enable + commas: enable + comments: + level: warning + comments-indentation: + level: warning + document-end: disable + document-start: + level: warning + empty-lines: enable + empty-values: disable + float-values: disable + hyphens: enable + indentation: enable + key-duplicates: enable + key-ordering: disable + # line-length: + # max: 150 + # level: warning + new-line-at-end-of-file: enable + new-lines: enable + octal-values: disable + quoted-strings: disable + trailing-spaces: enable + truthy: disable diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md new file mode 100644 index 0000000..403e5e0 --- /dev/null +++ b/cpk_lib_python_github/README.md @@ -0,0 +1,503 @@ +# 🔑 GitHub App Token Generator + +A powerful CLI tool for generating, managing, and analyzing GitHub App installation tokens. This tool simplifies the process of working with GitHub Apps by providing easy token generation, validation, and comprehensive app analysis. + +## 📋 Table of Contents + +- [Features](#-features) +- [Installation](#-installation) +- [Quick Start](#-quick-start) +- [Usage Examples](#-usage-examples) +- [Command Reference](#-command-reference) +- [Environment Variables](#-environment-variables) +- [Sample Outputs](#-sample-outputs) +- [Common Use Cases](#-common-use-cases) +- [Troubleshooting](#-troubleshooting) + +## ✨ Features + +- 🔐 **Token Generation**: Generate GitHub App installation tokens for organizations or specific installations +- ✅ **Token Validation**: Validate existing tokens and check their permissions +- đŸ—‘ī¸ **Token Revocation**: Safely revoke tokens with confirmation prompts +- 📊 **App Analysis**: Comprehensive analysis of GitHub App permissions, installations, and repositories +- 🔧 **Flexible Authentication**: Support for both private key files and direct key content +- 🌍 **Environment Variables**: Full support for environment-based configuration +- 🎨 **Rich Output**: Colorized, well-formatted output with emojis and clear sections +- 📝 **Debug Mode**: Detailed logging for troubleshooting + +## 🚀 Installation + +### Prerequisites + +- Python 3.9 or higher +- A GitHub App with appropriate permissions +- GitHub App private key + +### Install from Source + +```bash +# Clone the repository +git clone +cd cpk-lib-python-github + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install the package +pip install -e . +``` + +### Verify Installation + +```bash +github-app-token-generator --help +``` + +## đŸŽ¯ Quick Start + +### 1. Set up Environment Variables (Recommended) + +```bash +export APP_ID=${{YOUR_APP_ID}} +export PRIVATE_KEY_PATH=bot.pem +``` + +### 2. Generate a Token for Your Organization + +```bash +github-app-token-generator --org orginc +``` + +### 3. List All Available Installations + +```bash +github-app-token-generator --list-installations +``` + +## 📖 Usage Examples + +### 🔑 Token Generation + +#### Generate token by organization name: +```bash +github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +#### Generate token by installation ID: +```bash +github-app-token-generator --installation-id ${{YOUR_INST_ID}} --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +#### Using private key content directly: +```bash +github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key "$(cat /path/to/bot.pem)" +``` + +### 📋 Installation Management + +#### List all installations: +```bash +github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +#### Show installations (default behavior): +```bash +github-app-token-generator --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +### 🔍 App Analysis + +#### Comprehensive app analysis: +```bash +github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +### 🔐 Token Management + +#### Validate an existing token: +```bash +github-app-token-generator --validate-token ghs_xxx +``` + +#### Revoke a token (with confirmation): +```bash +github-app-token-generator --revoke-token ghs_xxx +``` + +#### Force revoke a token (no confirmation): +```bash +github-app-token-generator --revoke-token ghs_xxx --force +``` + +### 🐛 Debug & Help + +#### Enable debug logging: +```bash +github-app-token-generator --debug --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +#### Show help: +```bash +github-app-token-generator --help +``` + +## 📚 Command Reference + +| Command | Description | Required Arguments | +|---------|-------------|-------------------| +| `--org ` | Generate token for organization | `--app-id`, `--private-key-path` or `--private-key` | +| `--installation-id ` | Generate token for installation ID | `--app-id`, `--private-key-path` or `--private-key` | +| `--list-installations` | List all app installations | `--app-id`, `--private-key-path` or `--private-key` | +| `--analyze-app` | Comprehensive app analysis | `--app-id`, `--private-key-path` or `--private-key` | +| `--validate-token ` | Validate existing token | None | +| `--revoke-token ` | Revoke existing token | None | +| `--force` | Skip confirmation prompts | Used with `--revoke-token` | +| `--debug` | Enable debug logging | None | +| `--help` | Show help message | None | + +## 🌍 Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `APP_ID` | GitHub App ID | `${{YOUR_APP_ID}}` | +| `PRIVATE_KEY_PATH` | Path to private key file | `bot.pem` | +| `PRIVATE_KEY` | Private key content directly | `"$(cat /path/to/bot.pem)"` | + +### Setting Environment Variables + +```bash +# Using file path (recommended) +export APP_ID=${{YOUR_APP_ID}} +export PRIVATE_KEY_PATH=bot.pem + +# Using key content from file +export APP_ID=${{YOUR_APP_ID}} +export PRIVATE_KEY="$(cat /path/to/bot.pem)" + +# Then use shorter commands +github-app-token-generator --org orginc +github-app-token-generator --list-installations +github-app-token-generator --analyze-app +``` + +## 🎨 Sample Outputs + +### 📋 List Installations Output + +```bash +$ github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +**Output:** +``` +=== Available GitHub App Installations === + +Installation ID | Account | Target Type +------------------------------------------------------------ +${{YOUR_INST_ID}} | orginc | Organization +87654321 | mycompany | Organization +12345678 | individual-user | User + +â„šī¸ Found 3 installation(s) +💡 Use --org or --installation-id to get a token +``` + +### 🔑 Token Generation Output + +```bash +$ github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +**Output:** +``` +ghs_xxx +🔑 ✅ Token generated for organization 'orginc' +``` + +### ✅ Token Validation Output + +```bash +$ github-app-token-generator --validate-token ghs_xxx +``` + +**Output:** +``` +✅ Token is valid + Type: GitHub App Installation Token + Repositories: 25 + Scopes: GitHub App permissions + Rate limit: 4847/5000 +``` + +### ❌ Invalid Token Output + +```bash +$ github-app-token-generator --validate-token ghs_InvalidToken123456789 +``` + +**Output:** +``` +❌ Token is invalid or expired + Reason: Invalid or expired token +``` + +### 📊 App Analysis Output + +```bash +$ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +**Output:** +``` +=== GitHub App Analysis === + +🤖 App Information + ID: ${{YOUR_APP_ID}} + Name: Your org GitHub Bot + Slug: org-github-bot + Description: Automated GitHub operations for Your org + Owner: orginc + Owner Type: Organization + URL: https://github.com/apps/org-github-bot + Created: 2024-01-15 + +📍 Installation Summary + Total Installations: 2 + Total Repositories: 47 + Installed On: + ✅ orginc (Organization) - 2024-01-15 + ✅ org-dev (Organization) - 2024-02-01 + +🔐 App Permissions + âœī¸ contents: write + đŸ‘ī¸ metadata: read + âœī¸ pull_requests: write + đŸ‘ī¸ issues: read + 🔧 actions: write + +📡 Subscribed Events + 📨 issues + 📨 pull_request + 📨 push + 📨 release + +📚 Accessible Repositories (47 total) + orginc: + â€ĸ orginc/main-website + â€ĸ orginc/api-backend + â€ĸ orginc/mobile-app + â€ĸ orginc/infrastructure + org-dev: + â€ĸ org-dev/test-repo + â€ĸ org-dev/experimental-features + ... and 41 more repositories +``` + +### đŸ—‘ī¸ Token Revocation Output + +```bash +$ github-app-token-generator --revoke-token ghs_xxx +``` + +**Output:** +``` +=== Token Information === + Type: GitHub App Installation Token + Repositories: 25 + Scopes: GitHub App permissions + Rate limit: 4847/5000 + +âš ī¸ Are you sure you want to revoke this token? (y/N): y +đŸ—‘ī¸ ✅ Token revoked successfully +``` + +### đŸšĢ Organization Not Found Output + +```bash +$ github-app-token-generator --org nonexistent-org --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +**Output:** +``` +❌ App is not installed in organization: nonexistent-org +â„šī¸ Run with --list-installations to see available organizations +``` + +### 🐛 Debug Mode Output + +```bash +$ github-app-token-generator --debug --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +**Output:** +``` +2024-06-10 14:30:15,123 - __main__ - DEBUG - Debug logging enabled +2024-06-10 14:30:15,124 - __main__ - INFO - Starting GitHub App token generation +2024-06-10 14:30:15,124 - __main__ - INFO - App ID: ${{YOUR_APP_ID}} +2024-06-10 14:30:15,124 - __main__ - INFO - Private key path: bot.pem +2024-06-10 14:30:15,125 - __main__ - DEBUG - Reading private key from file: bot.pem +2024-06-10 14:30:15,126 - __main__ - DEBUG - Successfully read private key from: bot.pem +2024-06-10 14:30:15,127 - __main__ - DEBUG - Successfully generated JWT for app ID: ${{YOUR_APP_ID}} +2024-06-10 14:30:15,128 - __main__ - DEBUG - Fetching GitHub App installations +2024-06-10 14:30:15,456 - __main__ - INFO - Found 2 installations +2024-06-10 14:30:15,457 - __main__ - INFO - Looking for installation for organization: orginc +2024-06-10 14:30:15,458 - __main__ - INFO - Found installation ID ${{YOUR_INST_ID}} for organization: orginc +2024-06-10 14:30:15,459 - __main__ - DEBUG - Requesting access token for installation ID: ${{YOUR_INST_ID}} +2024-06-10 14:30:15,678 - __main__ - INFO - Successfully obtained access token for installation: ${{YOUR_INST_ID}} +ghs_xxx +🔑 ✅ Token generated for organization 'orginc' +``` + +## đŸŽ¯ Common Use Cases + +### 1. **CI/CD Pipeline Token Generation** + +```bash +# In your CI/CD script +export APP_ID=${{YOUR_APP_ID}} +export PRIVATE_KEY="$GITHUB_APP_PRIVATE_KEY" # From secrets + +TOKEN=$(github-app-token-generator --org orginc) +# Use $TOKEN for GitHub API calls +curl -H "Authorization: token $TOKEN" https://api.github.com/repos/orginc/myrepo +``` + +### 2. **Development Environment Setup** + +```bash +# Set up environment +export APP_ID=${{YOUR_APP_ID}} +export PRIVATE_KEY_PATH=bot.pem + +# Generate token for development +TOKEN=$(github-app-token-generator --org orginc) +echo "Your token: $TOKEN" +``` + +### 3. **Token Lifecycle Management** + +```bash +# Generate token +TOKEN=$(github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem) + +# Validate token before using +github-app-token-generator --validate-token $TOKEN + +# Use token for operations +# ... your work ... + +# Clean up - revoke token +github-app-token-generator --revoke-token $TOKEN --force +``` + +### 4. **App Health Monitoring** + +```bash +# Check app installations and permissions +github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem + +# List all installations +github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem + +# Validate existing tokens +github-app-token-generator --validate-token ghs_xxx +``` + +### 5. **Quick Token for Specific Installation** + +```bash +# If you know the installation ID +github-app-token-generator --installation-id ${{YOUR_INST_ID}} --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +## 🔧 Troubleshooting + +### Common Issues + +#### 1. **"Private key file not found"** +```bash +# Check file path +ls -la bot.pem + +# Use absolute path +github-app-token-generator --private-key-path /absolute/path/to/bot.pem +``` + +#### 2. **"App is not installed in organization"** +```bash +# List available installations +github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem + +# Use correct organization name from the list +``` + +#### 3. **"Invalid JWT token"** +```bash +# Check app ID and private key +github-app-token-generator --debug --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem + +``` + +#### 4. **"Token is invalid or expired"** +```bash +# Generate a new token +github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem + +# GitHub App tokens expire after 1 hour +``` + +### Debug Mode + +Enable debug logging for detailed troubleshooting: + +```bash +github-app-token-generator --debug --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem +``` + +This will show: +- 📝 Detailed API requests and responses +- 🔍 JWT generation details +- 📊 Installation lookup process +- âš ī¸ Warning messages and errors + +### Log Files + +The tool automatically creates log files: +- **Location**: `github_app_token.log` (in current directory) +- **Content**: All operations, errors, and debug information +- **Rotation**: Append mode (consider rotating large files) + +### Installation Setup + +Make sure you're in the virtual environment: + +```bash +# Make sure you're in the virtual environment +source venv/bin/activate + +# Reinstall the package to pick up new dependencies +pip install -e . + +# Now try running the command again +github-app-token-generator +``` + +## 📄 License + +This project is licensed under the GPLv3 License. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## 📞 Support + +For support and questions: +- 📧 Email: opencepk@gmail.com +- 🐛 Issues: Submit via GitHub Issues +- 📚 Documentation: This README and built-in help (`--help`) + +--- + +**Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** diff --git a/cpk_lib_python_github/__init__.py b/cpk_lib_python_github/__init__.py new file mode 100644 index 0000000..37514df --- /dev/null +++ b/cpk_lib_python_github/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""GitHub App token generator package.""" +from .github_app_token_generator_package.github_app_token_generator.main import ( + generate_jwt, + get_installation_access_token, + list_installations, + revoke_installation_token, + validate_token, +) + +__all__ = [ + "get_installation_access_token", + "list_installations", + "validate_token", + "revoke_installation_token", +] diff --git a/cpk_lib_python_github/github_app_token_generator_package/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/__init__.py new file mode 100644 index 0000000..5b5640d --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""GitHub App token generator package.""" +__all__ = [] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py new file mode 100644 index 0000000..7b2b516 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +"""GitHub App token generator module.""" +from .main import ( + generate_jwt, + get_installation_access_token, + list_installations, + revoke_installation_token, + validate_token, +) + +__all__ = [ + "generate_jwt", + "get_installation_access_token", + "list_installations", + "validate_token", + "revoke_installation_token", +] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__main__.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__main__.py new file mode 100644 index 0000000..01f16d5 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__main__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""CLI entry point.""" +from .main import main + +if __name__ == "__main__": + main() diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py new file mode 100644 index 0000000..eddde4a --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -0,0 +1,1105 @@ +# -*- coding: utf-8 -*- +""" +GitHub App Token Generator + +This module provides functionality to generate GitHub App installation tokens +for CLI use and automation scripts. +""" +import argparse +import logging +import os +import sys +import time + +import jwt +import requests +from colorama import Fore, Style, init + +# Initialize colorama +init(autoreset=True) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("github_app_token.log"), logging.StreamHandler()], +) + +logger = logging.getLogger(__name__) + + +def read_private_key(private_key_path): + """ + Read the private key from a file. + + Args: + private_key_path (str): The path to the private key file. + + Returns: + str: The content of the private key file. + + Raises: + FileNotFoundError: If the private key file is not found. + IOError: If there's an error reading the file. + """ + try: + with open(private_key_path, "r", encoding="utf-8") as key_file: + content = key_file.read() + logger.debug("Successfully read private key from: %s", private_key_path) + return content + except FileNotFoundError as error: + logger.error("Private key file not found: %s", private_key_path) + raise error + except IOError as error: + logger.error("Error reading private key file: %s", error) + raise error + + +def get_private_key_content(private_key_path=None, private_key_content=None): + """ + Get private key content from either file path or direct content. + + Args: + private_key_path (str, optional): Path to private key file. + private_key_content (str, optional): Direct private key content. + + Returns: + str: The private key content. + + Raises: + ValueError: If neither or both options are provided. + FileNotFoundError: If the private key file is not found. + IOError: If there's an error reading the file. + """ + if private_key_path and private_key_content: + raise ValueError("Cannot specify both --private-key-path and --private-key") + + if not private_key_path and not private_key_content: + raise ValueError("Must specify either --private-key-path or --private-key") + + if private_key_content: + logger.debug("Using private key content provided directly") + return private_key_content + + if private_key_path: + logger.debug("Reading private key from file: %s", private_key_path) + return read_private_key(private_key_path) + + # This shouldn't be reached, but just in case + raise ValueError("No private key source provided") + + +def generate_jwt(app_id, private_key): + """ + Generate a JWT for the GitHub App to provide the app identity to Github + (will be used to generate installation token). + + Args: + app_id (str): The GitHub App ID. + private_key (str): The private key for the GitHub App. + + Returns: + str: The generated JWT. + + Raises: + ValueError: If the JWT generation fails. + """ + try: + payload = { + "iat": int(time.time()), + "exp": int(time.time()) + (10 * 60), # JWT expiration time (10 minutes) + "iss": app_id, + } + token = jwt.encode(payload, private_key, algorithm="RS256") + logger.debug("Successfully generated JWT for app ID: %s", app_id) + return token + except Exception as error: + logger.error("Failed to generate JWT: %s", error) + raise ValueError(f"Failed to generate JWT: {error}") from error + + +def get_installation_access_token(jwt_token, installation_id): + """ + Get an installation access token for the GitHub App. + + Args: + jwt_token (str): The JWT for the GitHub App. + installation_id (int): The installation ID of the GitHub App. + + Returns: + str: The installation access token. + + Raises: + requests.exceptions.HTTPError: If an HTTP error occurs. + requests.exceptions.RequestException: If a request error occurs. + ValueError: If the response is invalid. + """ + url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Requesting access token for installation ID: %s", installation_id) + response = requests.post(url, headers=headers, timeout=30) + response.raise_for_status() + + token_data = response.json() + if "token" not in token_data: + raise ValueError("Invalid response: token not found in response") + + logger.info( + "Successfully obtained access token for installation: %s", installation_id + ) + return token_data["token"] + + except requests.exceptions.HTTPError as http_error: + logger.error("HTTP error occurred while getting access token: %s", http_error) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error("Request error occurred while getting access token: %s", req_error) + raise req_error + except ValueError as val_error: + logger.error("Invalid response format: %s", val_error) + raise val_error + + +def list_installations(jwt_token): + """ + List installations of the GitHub App + (which org the app is installed with other details such as installation id). + + Args: + jwt_token (str): The JWT for the GitHub App. + + Returns: + list: A list of installations. + + Raises: + requests.exceptions.HTTPError: If an HTTP error occurs. + requests.exceptions.RequestException: If a request error occurs. + """ + url = "https://api.github.com/app/installations" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Fetching GitHub App installations") + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + installations = response.json() + logger.info("Found %d installations", len(installations)) + return installations + + except requests.exceptions.HTTPError as http_error: + logger.error("HTTP error occurred while listing installations: %s", http_error) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error( + "Request error occurred while listing installations: %s", req_error + ) + raise req_error + + +def revoke_installation_token(token): + """ + Revoke an installation access token. + + Args: + token (str): The installation access token to revoke. + + Returns: + bool: True if revocation was successful, False otherwise. + + Raises: + requests.exceptions.HTTPError: If an HTTP error occurs. + requests.exceptions.RequestException: If a request error occurs. + """ + url = "https://api.github.com/installation/token" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Attempting to revoke installation token") + response = requests.delete(url, headers=headers, timeout=30) + response.raise_for_status() + + logger.info("Successfully revoked installation token") + return True + + except requests.exceptions.HTTPError as http_error: + if response.status_code == 404: + logger.warning("Token not found or already revoked") + return False + logger.error("HTTP error occurred while revoking token: %s", http_error) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error("Request error occurred while revoking token: %s", req_error) + raise req_error + + +def validate_token(token): + """ + Validate if a token is still active by checking installation info. + + Args: + token (str): The token to validate. + + Returns: + dict: Token info if valid, error info if invalid. + """ + url = "https://api.github.com/installation/repositories" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Validating GitHub App installation token") + response = requests.get(url, headers=headers, timeout=30) + + if response.status_code == 200: + repo_info = response.json() + scopes_header = response.headers.get("X-OAuth-Scopes", "") + scopes = scopes_header.split(", ") if scopes_header else [] + + rate_limit = { + "remaining": response.headers.get("X-RateLimit-Remaining"), + "limit": response.headers.get("X-RateLimit-Limit"), + "reset": response.headers.get("X-RateLimit-Reset"), + } + + return { + "valid": True, + "type": "GitHub App Installation Token", + "repositories_count": repo_info.get("total_count", 0), + "scopes": scopes, + "rate_limit": rate_limit, + } + if response.status_code == 401: + logger.info("Token validation failed: 401 - Token is invalid or expired") + return { + "valid": False, + "status_code": response.status_code, + "reason": "Invalid or expired token", + } + if response.status_code == 403: + logger.info("Token validation failed: 403 - Insufficient permissions") + return { + "valid": False, + "status_code": response.status_code, + "reason": "Insufficient permissions", + } + + logger.info("Token validation failed: %s", response.status_code) + return {"valid": False, "status_code": response.status_code} + + except requests.exceptions.RequestException as req_error: + logger.error("Error validating token: %s", req_error) + return {"valid": False, "error": str(req_error)} + + +def _fetch_installation_repos(jwt_token, installation): + """ + Helper function to fetch repositories for a single installation. + + Args: + jwt_token (str): JWT token for the GitHub App + installation (dict): Installation information + + Returns: + tuple: (repos_list, repo_count, permissions_set, events_set) + """ + installation_id = installation["id"] + result_data = { + "repos": [], + "repo_count": 0, + "permissions_set": set(), + "events_set": set(), + } + + try: + install_token = get_installation_access_token(jwt_token, installation_id) + + # Get repositories for this installation + repo_response = requests.get( + "https://api.github.com/installation/repositories", + headers={ + "Authorization": f"token {install_token}", + "Accept": "application/vnd.github.v3+json", + }, + timeout=30, + ) + + if repo_response.status_code == 200: + repo_data = repo_response.json() + result_data["repo_count"] = repo_data.get("total_count", 0) + + # Add ALL repositories (just names and installation) + for repo in repo_data.get("repositories", []): + result_data["repos"].append( + { + "name": repo["full_name"], + "installation": installation["account"]["login"], + } + ) + + # Track permissions and events + result_data["permissions_set"].update( + installation.get("permissions", {}).keys() + ) + result_data["events_set"].update(installation.get("events", [])) + + except Exception as e: + logger.warning( + "Could not fetch repos for installation %s: %s", + installation_id, + e, + ) + + return ( + result_data["repos"], + result_data["repo_count"], + result_data["permissions_set"], + result_data["events_set"], + ) + + +def get_github_app_details(jwt_token): + """ + Get detailed information about the GitHub App. + + Args: + jwt_token (str): JWT token for the GitHub App + + Returns: + dict: Comprehensive app information + """ + logger.info("Fetching GitHub App details") + + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "GitHub-App-Token-Generator/1.2.0", + } + + app_details = { + "valid": False, + "app_info": None, + "installations": [], + "total_repositories": 0, + "repository_sample": [], + "permissions_summary": {}, + "events_summary": [], + "error": None, + } + + try: + # 1. Get the GitHub App information + app_response = requests.get( + "https://api.github.com/app", headers=headers, timeout=30 + ) + + if app_response.status_code == 200: + app_details["valid"] = True + app_data = app_response.json() + + app_details["app_info"] = { + "id": app_data.get("id"), + "name": app_data.get("name"), + "slug": app_data.get("slug"), + "description": app_data.get("description"), + "owner": app_data.get("owner", {}), + "external_url": app_data.get("external_url"), + "html_url": app_data.get("html_url"), + "created_at": app_data.get("created_at"), + "updated_at": app_data.get("updated_at"), + "permissions": app_data.get("permissions", {}), + "events": app_data.get("events", []), + } + + # 2. Get installations and process repository data + app_details["installations"] = list_installations(jwt_token) + + # 3. Aggregate data from all installations + repo_aggregation = { + "total_repos": 0, + "all_repos": [], + "permissions_used": set(), + "events_used": set(), + } + + for installation in app_details["installations"]: + repos, count, perms, events = _fetch_installation_repos( + jwt_token, installation + ) + repo_aggregation["all_repos"].extend(repos) + repo_aggregation["total_repos"] += count + repo_aggregation["permissions_used"].update(perms) + repo_aggregation["events_used"].update(events) + + # 4. Update app_details with aggregated data + app_details["total_repositories"] = repo_aggregation["total_repos"] + app_details["repository_sample"] = repo_aggregation["all_repos"] + app_details["permissions_summary"] = dict(app_data.get("permissions", {})) + app_details["events_summary"] = list(repo_aggregation["events_used"]) + + elif app_response.status_code == 401: + app_details["error"] = "Invalid JWT token" + elif app_response.status_code == 403: + app_details["error"] = "Insufficient permissions" + else: + app_details["error"] = f"HTTP {app_response.status_code}" + + except requests.exceptions.RequestException as e: + app_details["error"] = f"Request failed: {str(e)}" + + return app_details + + +def _print_app_info(app_info): + """Helper function to print app information section.""" + print(f"\n{Fore.YELLOW}🤖 App Information{Style.RESET_ALL}") + print( + f" {Style.DIM}ID:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['id']}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Name:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['name']}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Slug:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['slug']}{Style.RESET_ALL}" + ) + + if app_info["description"]: + print( + f" {Style.DIM}Description:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['description']}{Style.RESET_ALL}" + ) + + owner = app_info["owner"] + if owner: + print( + f" {Style.DIM}Owner:{Style.RESET_ALL} " + f"{Fore.CYAN}{owner.get('login', 'Unknown')}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Owner Type:{Style.RESET_ALL} " + f"{Fore.CYAN}{owner.get('type', 'Unknown')}{Style.RESET_ALL}" + ) + + if app_info["html_url"]: + print( + f" {Style.DIM}URL:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['html_url']}{Style.RESET_ALL}" + ) + + print( + f" {Style.DIM}Created:{Style.RESET_ALL} " + f"{Fore.CYAN}{app_info['created_at'][:10]}{Style.RESET_ALL}" + ) + + +def _print_installations(installations, total_repositories): + """Helper function to print installations section.""" + print(f"\n{Fore.YELLOW}📍 Installation Summary{Style.RESET_ALL}") + print( + f" {Style.DIM}Total Installations:{Style.RESET_ALL} " + f"{Fore.CYAN}{len(installations)}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Total Repositories:{Style.RESET_ALL} " + f"{Fore.CYAN}{total_repositories}{Style.RESET_ALL}" + ) + + if installations: + print(f" {Style.DIM}Installed On:{Style.RESET_ALL}") + for installation in installations: + account = installation["account"] + target_type = installation["target_type"] + created = installation["created_at"][:10] + status_icon = "✅" if not installation.get("suspended_at") else "âš ī¸" + + print( + f" {status_icon} {Fore.CYAN}{account['login']}{Style.RESET_ALL} " + f"({target_type}) - {created}" + ) + + +def _print_permissions(permissions): + """Helper function to print permissions section.""" + if permissions: + print(f"\n{Fore.YELLOW}🔐 App Permissions{Style.RESET_ALL}") + for perm, level in permissions.items(): + color = ( + Fore.GREEN + if level == "write" + else Fore.YELLOW if level == "read" else Fore.BLUE + ) + icon = "âœī¸" if level == "write" else "đŸ‘ī¸" if level == "read" else "🔧" + print(f" {icon} {color}{perm}: {level}{Style.RESET_ALL}") + + +def _print_events(events): + """Helper function to print events section.""" + if events: + print(f"\n{Fore.YELLOW}📡 Subscribed Events{Style.RESET_ALL}") + for event in sorted(events): + print(f" 📨 {Fore.CYAN}{event}{Style.RESET_ALL}") + + +def _print_repositories(repos, total_repositories): + """Helper function to print repositories section.""" + if repos: + print( + f"\n{Fore.YELLOW}📚 Accessible Repositories ({total_repositories} total)" + f"{Style.RESET_ALL}" + ) + + # Group repositories by installation for better organization + repos_by_installation = {} + for repo in repos: + installation = repo["installation"] + if installation not in repos_by_installation: + repos_by_installation[installation] = [] + repos_by_installation[installation].append(repo["name"]) + + # Print repositories grouped by installation + for installation, repo_names in repos_by_installation.items(): + print(f" {Style.DIM}{installation}:{Style.RESET_ALL}") + for repo_name in repo_names: + print(f" â€ĸ {Fore.CYAN}{repo_name}{Style.RESET_ALL}") + + # Show remaining count if there are more repos + if total_repositories > len(repos): + remaining = total_repositories - len(repos) + print( + f" {Style.DIM}... and {remaining} more repositories" + f"{Style.RESET_ALL}" + ) + + +def print_app_analysis(app_details): + """ + Print comprehensive GitHub App analysis. + + Args: + app_details (dict): App analysis results + """ + print(f"\n{Fore.CYAN}{Style.BRIGHT}=== GitHub App Analysis ==={Style.RESET_ALL}") + + if not app_details["valid"]: + print(f"{Fore.RED}❌ Unable to analyze app{Style.RESET_ALL}") + if app_details["error"]: + print( + f" {Style.DIM}Error:{Style.RESET_ALL} " + f"{Fore.RED}{app_details['error']}{Style.RESET_ALL}" + ) + return + + app_info = app_details["app_info"] + installations = app_details["installations"] + permissions = app_details["permissions_summary"] + events = app_details["events_summary"] + repos = app_details["repository_sample"] + total_repos = app_details["total_repositories"] + + # Print each section using helper functions + _print_app_info(app_info) + _print_installations(installations, total_repos) + _print_permissions(permissions) + _print_events(events) + _print_repositories(repos, total_repos) + + +def handle_app_analysis(app_id, private_key_path=None, private_key_content=None): + """ + Handle GitHub App analysis command. + + Args: + app_id (str): GitHub App ID + private_key_path (str, optional): Path to private key file + private_key_content (str, optional): Private key content directly + """ + logger.info("Starting GitHub App analysis") + + try: + private_key = get_private_key_content(private_key_path, private_key_content) + jwt_token = generate_jwt(app_id, private_key) + + app_details = get_github_app_details(jwt_token) + print_app_analysis(app_details) + + except Exception as e: + print(f"{Fore.RED}❌ Error analyzing app: {e}{Style.RESET_ALL}") + logger.error("App analysis failed: %s", e) + + +def revoke_with_confirmation(token, force=False): + """ + Revoke token with user confirmation and validation. + + Args: + token (str): The token to revoke. + force (bool): Skip confirmation if True. + + Returns: + bool: True if revocation was successful. + """ + # First validate the token + token_info = validate_token(token) + + if not token_info["valid"]: + print(f"{Fore.RED}❌ Token is already invalid or expired{Style.RESET_ALL}") + if "reason" in token_info: + reason = token_info["reason"] + print( + f" {Style.DIM}Reason:{Style.RESET_ALL}" + f" {Fore.RED}{reason}{Style.RESET_ALL}" + ) + return False + + print(f"\n{Fore.CYAN}{Style.BRIGHT}=== Token Information ==={Style.RESET_ALL}") + token_type = token_info.get("type", "Unknown") + repo_count = token_info.get("repositories_count", "N/A") + print( + f" {Style.DIM}Type:{Style.RESET_ALL} {Fore.CYAN}{token_type}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Repositories:{Style.RESET_ALL}" + f" {Fore.CYAN}{repo_count}{Style.RESET_ALL}" + ) + + scopes = token_info.get("scopes", []) + scopes_text = ( + ", ".join(scopes) if scopes and scopes != [""] else "GitHub App permissions" + ) + print( + f" {Style.DIM}Scopes:{Style.RESET_ALL}" + f" {Fore.CYAN}{scopes_text}{Style.RESET_ALL}" + ) + + rate_limit = token_info["rate_limit"] + rate_limit_text = f"{rate_limit['remaining']}/{rate_limit['limit']}" + print( + f" {Style.DIM}Rate limit:{Style.RESET_ALL}" + f" {Fore.CYAN}{rate_limit_text}{Style.RESET_ALL}" + ) + + if not force: + prompt = ( + f"\n{Fore.YELLOW}âš ī¸ Are you sure you want to revoke this token? (y/N): " + f"{Style.RESET_ALL}" + ) + confirmation = input(prompt) + if confirmation.lower() not in ["y", "yes"]: + print(f"{Fore.RED}đŸšĢ Token revocation cancelled{Style.RESET_ALL}") + return False + + try: + success = revoke_installation_token(token) + if success: + print(f"{Fore.GREEN}đŸ—‘ī¸ ✅ Token revoked successfully{Style.RESET_ALL}") + return True + print(f"{Fore.RED}❌ Failed to revoke token{Style.RESET_ALL}") + return False + except Exception as error: + print(f"{Fore.RED}❌ Error revoking token: {error}{Style.RESET_ALL}") + return False + + +def print_installation_table(installations): + """ + Print installations in a formatted table. + + Args: + installations (list): List of installation dictionaries. + """ + print( + f"\n{Fore.CYAN}{Style.BRIGHT}=== Available" + f" GitHub App Installations ==={Style.RESET_ALL}" + ) + + if not installations: + print(f"{Fore.YELLOW}âš ī¸ No installations found{Style.RESET_ALL}") + return + + # Table header + header = ( + f"{Style.BRIGHT}{'Installation ID':<20} | {'Account':<20} | " + f"{'Target Type':<15}{Style.RESET_ALL}" + ) + print(header) + print(f"{Style.DIM}{'-' * 60}{Style.RESET_ALL}") + + # Table rows + for installation in installations: + installation_id = installation["id"] + account_name = installation["account"]["login"] + target_type = installation["target_type"] + + row = ( + f"{Fore.CYAN}{installation_id:<20}{Style.RESET_ALL} | " + f"{Fore.CYAN}{account_name:<20}{Style.RESET_ALL} | " + f"{Fore.CYAN}{target_type:<15}{Style.RESET_ALL}" + ) + print(row) + + # Footer info + count_msg = f"{len(installations)} installation(s)" + print(f"\n{Fore.BLUE}â„šī¸ Found {count_msg}{Style.RESET_ALL}") + print( + f"{Fore.BLUE}💡 Use --org or" + f" --installation-id to get a token{Style.RESET_ALL}" + ) + + +def handle_token_validation(token): + """ + Handle token validation command. + + Args: + token (str): The token to validate. + """ + logger.info("Validating token") + token_info = validate_token(token) + + if token_info["valid"]: + print(f"{Fore.GREEN}✅ Token is valid{Style.RESET_ALL}") + token_type = token_info.get("type", "Unknown") + repo_count = token_info.get("repositories_count", "N/A") + + print( + f" {Style.DIM}Type:{Style.RESET_ALL}" + f" {Fore.CYAN}{token_type}{Style.RESET_ALL}" + ) + print( + f" {Style.DIM}Repositories:{Style.RESET_ALL}" + f" {Fore.CYAN}{repo_count}{Style.RESET_ALL}" + ) + + scopes = token_info.get("scopes", []) + scopes_text = ( + ", ".join(scopes) if scopes and scopes != [""] else "GitHub App permissions" + ) + print( + f" {Style.DIM}Scopes:{Style.RESET_ALL}" + f" {Fore.CYAN}{scopes_text}{Style.RESET_ALL}" + ) + + rate_limit = token_info["rate_limit"] + rate_limit_text = f"{rate_limit['remaining']}/{rate_limit['limit']}" + print( + f" {Style.DIM}Rate limit:{Style.RESET_ALL}" + f" {Fore.CYAN}{rate_limit_text}{Style.RESET_ALL}" + ) + return + + print(f"{Fore.RED}❌ Token is invalid or expired{Style.RESET_ALL}") + if "reason" in token_info: + reason = token_info["reason"] + print( + f" {Style.DIM}Reason:{Style.RESET_ALL} {Fore.RED}{reason}{Style.RESET_ALL}" + ) + elif "error" in token_info: + error_msg = token_info["error"] + print( + f" {Style.DIM}Error:{Style.RESET_ALL} " + f" {Fore.RED}{error_msg}{Style.RESET_ALL}" + ) + + +def handle_organization_token(org, installations, jwt_token): + """ + Handle organization token generation. + + Args: + org (str): Organization name. + installations (list): List of installations. + jwt_token (str): JWT token for GitHub App. + + Returns: + bool: True if successful, False if organization not found. + """ + logger.info("Looking for installation for organization: %s", org) + org_installation = next( + (inst for inst in installations if inst["account"]["login"] == org), + None, + ) + + if org_installation: + installation_id = org_installation["id"] + logger.info( + "Found installation ID %s for organization: %s", + installation_id, + org, + ) + installation_token = get_installation_access_token(jwt_token, installation_id) + print(installation_token) + success_msg = f"Token generated for organization '{org}'" + print(f"{Fore.GREEN}🔑 ✅ {success_msg}{Style.RESET_ALL}", file=sys.stderr) + return True + + logger.error("App is not installed in organization: %s", org) + error_msg = f"App is not installed in organization: {org}" + print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) + info_msg = "Run with --list-installations to see available organizations" + print(f"{Fore.BLUE}â„šī¸ {info_msg}{Style.RESET_ALL}", file=sys.stderr) + return False + + +def setup_argument_parser(): + """ + Set up and return the argument parser. + + Returns: + argparse.ArgumentParser: Configured argument parser. + """ + parser = argparse.ArgumentParser( + description="Generate and manage GitHub App installation tokens", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Generate tokens (using private key file) + %(prog)s --org myorg --app-id 12345 --private-key-path /path/to/key.pem + + # Generate tokens (using private key content) + %(prog)s --org myorg --app-id 12345 --private-key "Content of the private Key" + + %(prog)s --installation-id 98765 --app-id 12345 --private-key-path /path/to/key.pem + + # List installations + %(prog)s --list-installations --app-id 12345 --private-key-path /path/to/key.pem + + # Analyze GitHub App + %(prog)s --analyze-app --app-id 12345 --private-key "Content of the private Key" + + # Token management + %(prog)s --validate-token ghs_xxxxxxxxxxxxx + %(prog)s --revoke-token ghs_xxxxxxxxxxxxx + %(prog)s --revoke-token ghs_xxxxxxxxxxxxx --force + +Environment Variables: + APP_ID GitHub App ID + PRIVATE_KEY_PATH Path to private key file + PRIVATE_KEY Private key content directly + """, + ) + + # Existing arguments + parser.add_argument( + "--app-id", + help="GitHub App ID (or set APP_ID env var)", + metavar="ID", + ) + + # Create mutually exclusive group for private key options + key_group = parser.add_mutually_exclusive_group() + key_group.add_argument( + "--private-key-path", + help="Path to private key file (or set PRIVATE_KEY_PATH env var)", + metavar="PATH", + ) + key_group.add_argument( + "--private-key", + help="Private key content directly (or set PRIVATE_KEY env var)", + metavar="KEY_CONTENT", + ) + + parser.add_argument( + "--org", + help="Organization name to get token for", + metavar="ORG", + ) + parser.add_argument( + "--installation-id", + help="Installation ID (if known)", + metavar="ID", + ) + parser.add_argument( + "--list-installations", + action="store_true", + help="List all installations", + ) + + # App analysis argument + parser.add_argument( + "--analyze-app", + action="store_true", + help="Analyze GitHub App details, permissions, and repositories", + ) + + # Token management arguments + parser.add_argument( + "--validate-token", + help="Validate an existing token", + metavar="TOKEN", + ) + parser.add_argument( + "--revoke-token", + help="Revoke an existing token", + metavar="TOKEN", + ) + parser.add_argument( + "--force", + action="store_true", + help="Skip confirmation prompts", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging", + ) + + return parser + + +def validate_credentials(app_id, private_key_path=None, private_key_content=None): + """Validate required credentials are provided.""" + if not app_id: + logger.error("GitHub App ID is required") + error_msg = ( + "GitHub App ID required. Use --app-id or set APP_ID environment variable" + ) + print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) + sys.exit(1) + + if not private_key_path and not private_key_content: + logger.error("Private key is required") + error_msg = ( + "Private key required. Use --private-key-path, --private-key, or set " + "PRIVATE_KEY_PATH/PRIVATE_KEY environment variable" + ) + print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) + sys.exit(1) + + +def handle_token_operations(args): + """Handle token validation and revocation operations.""" + # Handle token validation (no app-id/private-key required) + if args.validate_token: + handle_token_validation(args.validate_token) + return True + + # Handle token revocation (no app-id/private-key required) + if args.revoke_token: + logger.info("Revoking token") + success = revoke_with_confirmation(args.revoke_token, args.force) + sys.exit(0 if success else 1) + + return False + + +def handle_main_operations(args, app_id, private_key_path, private_key_content): + """Handle the main token generation operations.""" + try: + logger.info("Starting GitHub App token generation") + logger.info("App ID: %s", app_id) + + if private_key_path: + logger.info("Private key path: %s", private_key_path) + else: + logger.info("Private key provided directly") + + private_key = get_private_key_content(private_key_path, private_key_content) + jwt_token = generate_jwt(app_id, private_key) + installations = list_installations(jwt_token) + + if args.list_installations: + logger.info("Listing available installations") + print_installation_table(installations) + return + + # Handle specific installation ID + if args.installation_id: + logger.info("Getting token for installation ID: %s", args.installation_id) + installation_token = get_installation_access_token( + jwt_token, args.installation_id + ) + print(installation_token) + return + + # Handle organization lookup + if args.org: + success = handle_organization_token(args.org, installations, jwt_token) + sys.exit(0 if success else 1) + + # Default behavior - show all installations + logger.info("No specific organization or installation ID provided") + print_installation_table(installations) + + except FileNotFoundError: + logger.error("Private key file not found: %s", private_key_path) + error_msg = f"Private key file not found: {private_key_path}" + print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) + sys.exit(1) + except requests.exceptions.RequestException as req_error: + logger.error("API request failed: %s", req_error) + print( + f"{Fore.RED}❌ Error making API request: {req_error}{Style.RESET_ALL}", + file=sys.stderr, + ) + sys.exit(1) + except (ValueError, jwt.InvalidTokenError) as error: + logger.error("Token/validation error: %s", error) + print(f"{Fore.RED}❌ Error: {error}{Style.RESET_ALL}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print( + f"\n{Fore.YELLOW}âš ī¸ Operation cancelled by user{Style.RESET_ALL}", + file=sys.stderr, + ) + sys.exit(1) + except Exception as error: + logger.error("Unexpected error occurred: %s", error) + print( + f"{Fore.RED}❌ Unexpected error: {error}{Style.RESET_ALL}", + file=sys.stderr, + ) + print( + f"{Fore.BLUE}â„šī¸ Please report this issue with the above " + f"error message{Style.RESET_ALL}", + file=sys.stderr, + ) + sys.exit(1) + + +def main(): + """Main function to handle CLI arguments and generate GitHub App tokens.""" + parser = setup_argument_parser() + args = parser.parse_args() + + # Set debug logging if requested + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + logger.debug("Debug logging enabled") + + # Handle token operations first (don't need app credentials) + if handle_token_operations(args): + return + + # For other operations, app-id and private-key are required + app_id = args.app_id or os.getenv("APP_ID") + private_key_path = args.private_key_path or os.getenv("PRIVATE_KEY_PATH") + private_key_content = args.private_key or os.getenv("PRIVATE_KEY") + + # Handle app analysis + if args.analyze_app: + validate_credentials(app_id, private_key_path, private_key_content) + handle_app_analysis(app_id, private_key_path, private_key_content) + return + + validate_credentials(app_id, private_key_path, private_key_content) + handle_main_operations(args, app_id, private_key_path, private_key_content) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7e77124 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["cpk_lib_python_github"] +# packages = { find = { where = ["."] } } Just FYI in case we need it in the future: this can also be used but it is too permissive + +[project] +name = "cpk-lib-python-github" +version = "1.2.0" +description = "Common Python opencepk packages." +readme = "README.md" +license = { text = "GPLv3" } +authors = [{name = "CPK Cloud Engineering Platform Kit", email = "opencepk@gmail.com"}] +requires-python = ">=3.9" + +# Add this dependencies section +dependencies = [ + "PyJWT[crypto]>=2.0.0", + "requests>=2.25.0", + "toml>=0.10.0", + "colorama>=0.4.6" +] + +keywords = ["github", "app", "token", "file", "pre-commit", "hook", "git", "tool", "utility", "cpk"] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Topic :: File Formats :: JSON", + "Topic :: Software Development :: Pre-processors", + "Topic :: Software Development :: Version Control :: Git", + "Topic :: Text Processing", + "Topic :: Text Processing :: Filters", + "Topic :: Text Processing :: General", + "Topic :: Utilities" +] + +[project.urls] +Homepage = "https://github.com/cpk/cpk-lib-python-github" +Repository = "https://github.com/cpk/cpk-lib-python-github" +Issues = "https://github.com/cpk/cpk-lib-python-github/issues" +Documentation = "https://github.com/cpk/cpk-lib-python-github#readme" + +[project.entry-points."console_scripts"] +github-app-token-generator = "cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.main:main" + +[project.optional-dependencies] +dep = [ + "toml", + "black>=23.0.0", + "pylint>=2.17.0", + "mypy>=1.0.0", + "pre-commit>=3.0.0" + ] + + +[tool.black] +line-length = 88 +target-version = ['py39'] +include = '\.pyi?$' + +[tool.pylint.format] +max-line-length = 88 # Match Black's line length + +[tool.pylint.messages_control] +disable = [ + "broad-exception-caught", + "too-many-arguments", + "too-many-locals" +] + +[tool.pylint.design] +max-statements = 60 # Increase from default 50 to allow longer functions +max-module-lines = 1200 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9134e3b --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""Package setup configuration.""" +from setuptools import setup + +setup() From 134bcc8b9976093e161a2ee4bc8ce570c2951f9d Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 11 Jun 2025 12:07:22 -0300 Subject: [PATCH 02/24] feat/IDP-43: inital code --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7e77124..fa75673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,11 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = ["cpk_lib_python_github"] +packages = [ + "cpk_lib_python_github", + "cpk_lib_python_github.github_app_token_generator_package", + "cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator" +] # packages = { find = { where = ["."] } } Just FYI in case we need it in the future: this can also be used but it is too permissive [project] From b837d09c8fbee75de6fafef6932463ad7ac1559f Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 11 Jun 2025 12:09:19 -0300 Subject: [PATCH 03/24] feat/IDP-43: inital code --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fa75673..85a8362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,13 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = [ - "cpk_lib_python_github", - "cpk_lib_python_github.github_app_token_generator_package", - "cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator" -] +#packages = ["cpk_lib_python_github"] # packages = { find = { where = ["."] } } Just FYI in case we need it in the future: this can also be used but it is too permissive +[tool.setuptools.packages.find] +where = ["."] +include = ["cpk_lib_python_github*"] + [project] name = "cpk-lib-python-github" version = "1.2.0" From f57b7379907723bb43f71b3dd1eaa7d406bef122 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 11 Jun 2025 12:12:14 -0300 Subject: [PATCH 04/24] feat/IDP-43: inital code --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 85a8362..62f1356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,12 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -#packages = ["cpk_lib_python_github"] +# This is commented as the next section is a better approach for this use case but may need the following in the future depending the future decisions +# packages = [ +# "cpk_lib_python_github", +# "cpk_lib_python_github.github_app_token_generator_package", +# "cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator" +# ] # packages = { find = { where = ["."] } } Just FYI in case we need it in the future: this can also be used but it is too permissive [tool.setuptools.packages.find] From 2299bf906dfd80dd468467ce0bfe1b629033ef00 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 12 Jun 2025 12:31:11 -0300 Subject: [PATCH 05/24] feat/IDP-43: precommit --- .github/workflows/precommit.yaml | 24 ++++++++++++++ README.md | 33 +++++++++++++++++-- cpk_lib_python_github/README.md | 26 ++++++++++++--- .../github_app_token_generator/main.py | 16 ++++----- 4 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/precommit.yaml diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml new file mode 100644 index 0000000..eb72aa3 --- /dev/null +++ b/.github/workflows/precommit.yaml @@ -0,0 +1,24 @@ +--- +name: precommit + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, feat/IDP-43] + +permissions: + actions: read + checks: write + contents: read + pull-requests: write + +jobs: + pre-commit: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: pre-commit/action@v3.0.1 diff --git a/README.md b/README.md index 55133c7..47d91ac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ -# cpk-lib-python-github -cpk-lib-python-github +# 🐙 CPK GitHub Python Libraries + +A comprehensive collection of Python libraries for GitHub automation, management, and integration. This package provides a suite of tools designed to simplify GitHub operations for development teams, CI/CD pipelines, and automation scripts. + +## 📋 Overview + +**CPK GitHub Python Libraries** is a modular collection of GitHub-related utilities designed for: +- 🔐 **Authentication & Authorization** - GitHub App token management +- 🚀 **CI/CD Integration** - Seamless pipeline automation +- 📊 **Repository Management** - Bulk operations and analysis +- 🔧 **Development Tools** - CLI utilities for daily GitHub tasks + + +📚 **[Full Documentation](cpk_lib_python_github/README.md)** + + +## 📄 License + +This project is licensed under the **GPLv3 License** - see the [LICENSE](LICENSE) file for details. + +## 📞 Support & Community + +- 📧 **Email**: opencepk@gmail.com +- 🐛 **Issues**: [GitHub Issues](https://github.com/opencpk/cpk-lib-python-github/issues) +- đŸ’Ŧ **Discussions**: [GitHub Discussions](https://github.com/opencpk/cpk-lib-python-github/discussions) +- 📚 **Documentation**: [GitHub Wiki](https://github.com/opencpk/cpk-lib-python-github/wiki) + + +**Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** + +*Empowering development teams with powerful GitHub automation tools.* diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index 403e5e0..b296138 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -473,11 +473,8 @@ The tool automatically creates log files: Make sure you're in the virtual environment: ```bash -# Make sure you're in the virtual environment -source venv/bin/activate -# Reinstall the package to pick up new dependencies -pip install -e . +pip install git+https://github.com/opencpk/cpk-lib-python-github.git # Now try running the command again github-app-token-generator @@ -500,4 +497,25 @@ For support and questions: --- +## đŸ—ēī¸ Roadmap + +### Current (v1.x) +- ✅ GitHub App Token Generator +- ✅ CLI interface with rich output +- ✅ Comprehensive token management + +### Upcoming (v2.x) +- 🔄 Repository bulk operations +- 🔄 Issue lifecycle automation +- 🔄 Pull request workflow tools +- 🔄 Webhook processing utilities + +### Future (v3.x) +- 🔮 GitHub Actions integration +- 🔮 Advanced analytics and reporting +- 🔮 Multi-organization management +- 🔮 GraphQL API integration + +--- + **Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py index eddde4a..5650156 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -869,23 +869,23 @@ def setup_argument_parser(): epilog=""" Examples: # Generate tokens (using private key file) - %(prog)s --org myorg --app-id 12345 --private-key-path /path/to/key.pem + %(prog)s --org myorg --app-id $APP_ID --private-key-path /path/to/key.pem # Generate tokens (using private key content) - %(prog)s --org myorg --app-id 12345 --private-key "Content of the private Key" + %(prog)s --org myorg --app-id $APP_ID --private-key "Content of the private Key" - %(prog)s --installation-id 98765 --app-id 12345 --private-key-path /path/to/key.pem + %(prog)s --installation-id 98765 --app-id $APP_ID --private-key-path /path/to/key.pem # List installations - %(prog)s --list-installations --app-id 12345 --private-key-path /path/to/key.pem + %(prog)s --list-installations --app-id $APP_ID --private-key-path /path/to/key.pem # Analyze GitHub App - %(prog)s --analyze-app --app-id 12345 --private-key "Content of the private Key" + %(prog)s --analyze-app --app-id $APP_ID --private-key "Content of the private Key" # Token management - %(prog)s --validate-token ghs_xxxxxxxxxxxxx - %(prog)s --revoke-token ghs_xxxxxxxxxxxxx - %(prog)s --revoke-token ghs_xxxxxxxxxxxxx --force + %(prog)s --validate-token ghs_TOKEN + %(prog)s --revoke-token ghs_TOKEN + %(prog)s --revoke-token ghs_TOKEN --force Environment Variables: APP_ID GitHub App ID From 8dd86bdf21385a65c3ae855fdbde6cd996b64ea0 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 13 Jun 2025 16:06:32 -0300 Subject: [PATCH 06/24] IDP-43-modular --- .gitignore | 57 +- .pre-commit-config.yaml | 1 + cpk_lib_python_github/README.md | 238 +++- cpk_lib_python_github/__init__.py | 25 +- .../github_app_token_generator/__init__.py | 25 +- .../github_app_token_generator/auth.py | 65 + .../github_app_token_generator/cli.py | 88 ++ .../github_app_token_generator/config.py | 175 +++ .../github_app_token_generator/formatters.py | 314 +++++ .../github_app_token_generator/github_api.py | 227 ++++ .../github_app_token_generator/main.py | 1110 +---------------- .../token_manager.py | 196 +++ 12 files changed, 1380 insertions(+), 1141 deletions(-) create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py diff --git a/.gitignore b/.gitignore index c4f4aef..367524d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,41 @@ +# Development files +# IDEs +# Logs +# OS # Python -__pycache__/ -*.py[cod] +# Virtual environments *$py.class +*.egg +*.egg-info/ +*.log +*.py[cod] *.so +*.swo +*.swp +.DS_Store .Python +.eggs/ +.idea/ +.installed.cfg +.python-version +.vscode/ +ENV/ +MANIFEST +Thumbs.db +__pycache__/ build/ +deleted develop-eggs/ dist/ downloads/ eggs/ -.eggs/ +env/ lib/ lib64/ parts/ -sdist/ -var/ -wheels/ pip-wheel-metadata/ +sdist/ share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments +var/ venv/ -env/ -ENV/ - -# IDEs -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log - -# Development files -deleted -.python-version +wheels/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8449bf0..eb70602 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,7 @@ repos: - id: end-of-file-fixer - id: fix-encoding-pragma - id: file-contents-sorter + files: ^(requirements.*\.txt|\.gitignore)$ - id: check-case-conflict - id: mixed-line-ending args: [--fix=lf] diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index b296138..39fb7fe 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -12,6 +12,7 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat - [Environment Variables](#-environment-variables) - [Sample Outputs](#-sample-outputs) - [Common Use Cases](#-common-use-cases) +- [Python SDK Usage](#-python-sdk-usage) - [Troubleshooting](#-troubleshooting) ## ✨ Features @@ -117,17 +118,17 @@ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key #### Validate an existing token: ```bash -github-app-token-generator --validate-token ghs_xxx +github-app-token-generator --validate-token ghs_TOKEN ``` #### Revoke a token (with confirmation): ```bash -github-app-token-generator --revoke-token ghs_xxx +github-app-token-generator --revoke-token ghs_TOKEN ``` #### Force revoke a token (no confirmation): ```bash -github-app-token-generator --revoke-token ghs_xxx --force +github-app-token-generator --revoke-token ghs_TOKEN --force ``` ### 🐛 Debug & Help @@ -211,14 +212,14 @@ $ github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-ke **Output:** ``` -ghs_xxx +ghs_TOKEN 🔑 ✅ Token generated for organization 'orginc' ``` ### ✅ Token Validation Output ```bash -$ github-app-token-generator --validate-token ghs_xxx +$ github-app-token-generator --validate-token ghs_TOKEN ``` **Output:** @@ -297,19 +298,13 @@ $ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-k ### đŸ—‘ī¸ Token Revocation Output ```bash -$ github-app-token-generator --revoke-token ghs_xxx +$ github-app-token-generator --revoke-token ghs_TOKEN ``` **Output:** ``` -=== Token Information === - Type: GitHub App Installation Token - Repositories: 25 - Scopes: GitHub App permissions - Rate limit: 4847/5000 - âš ī¸ Are you sure you want to revoke this token? (y/N): y -đŸ—‘ī¸ ✅ Token revoked successfully +✅ Token revoked successfully ``` ### đŸšĢ Organization Not Found Output @@ -320,8 +315,7 @@ $ github-app-token-generator --org nonexistent-org --app-id ${{YOUR_APP_ID}} --p **Output:** ``` -❌ App is not installed in organization: nonexistent-org -â„šī¸ Run with --list-installations to see available organizations +❌ No installation found for organization: nonexistent-org ``` ### 🐛 Debug Mode Output @@ -345,7 +339,7 @@ $ github-app-token-generator --debug --org orginc --app-id ${{YOUR_APP_ID}} --pr 2024-06-10 14:30:15,458 - __main__ - INFO - Found installation ID ${{YOUR_INST_ID}} for organization: orginc 2024-06-10 14:30:15,459 - __main__ - DEBUG - Requesting access token for installation ID: ${{YOUR_INST_ID}} 2024-06-10 14:30:15,678 - __main__ - INFO - Successfully obtained access token for installation: ${{YOUR_INST_ID}} -ghs_xxx +ghs_TOKEN 🔑 ✅ Token generated for organization 'orginc' ``` @@ -401,7 +395,7 @@ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem # Validate existing tokens -github-app-token-generator --validate-token ghs_xxx +github-app-token-generator --validate-token ghs_TOKEN ``` ### 5. **Quick Token for Specific Installation** @@ -411,6 +405,207 @@ github-app-token-generator --validate-token ghs_xxx github-app-token-generator --installation-id ${{YOUR_INST_ID}} --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem ``` +## 🐍 Python SDK Usage + +### Basic Token Generation (Recommended) + +```python +from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient, TokenManager, OutputFormatter +from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config +import os + +# Create config (handles private key automatically) +config = Config( + app_id=os.getenv("GITHUB_APP_ID"), + private_key_path=os.getenv("GITHUB_PRIVATE_KEY_PATH"), # No manual reading needed! + timeout=30 +) + +# Use with token manager (which calls get_private_key_content internally) +api_client = GitHubAPIClient() +formatter = OutputFormatter() +token_manager = TokenManager(api_client, formatter) + +# This handles all the private key reading automatically +token_manager.generate_org_token(config, "your-organization") +``` + +### Alternative: Using Private Key Content + +```python +from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient +from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config +import os + +config = Config( + app_id=os.getenv("GITHUB_APP_ID"), + private_key_content=os.getenv("GITHUB_PRIVATE_KEY"), # Direct content + timeout=30 +) + + +# Generate token for specific organization +for installation in installations: + if installation.get("account", {}).get("login") == "your-organization": + installation_id = installation.get("id") + access_token = api_client.get_installation_access_token(jwt_token, installation_id) + print(f"Access token: {access_token}") + break +``` + +### Token Validation + +```python +from cpk_lib_python_github import GitHubAPIClient + +api_client = GitHubAPIClient() +result = api_client.validate_token("your_token_here") + +if result.get('valid'): + print(f"✅ Token is valid") + print(f"Type: {result.get('type')}") + print(f"Repositories: {result.get('repositories_count')}") +else: + print(f"❌ Token invalid: {result.get('reason')}") +``` + +### Complete Workflow Example + +```python +#!/usr/bin/env python3 +"""Complete GitHub App token workflow using the SDK.""" + +import os +from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter +from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config + +def main(): + # Create configuration from environment + config = Config( + app_id=os.getenv("GITHUB_APP_ID"), + private_key_path=os.getenv("GITHUB_PRIVATE_KEY_PATH"), + debug=True, + timeout=60 + ) + + # Initialize SDK components + api_client = GitHubAPIClient() + formatter = OutputFormatter(use_colors=True) + token_manager = TokenManager(api_client, formatter) + + try: + # List all installations + print("📋 Listing installations...") + token_manager.list_installations(config) + + # Generate token for organization + print("\n🔑 Generating token...") + token_manager.generate_org_token(config, "your-organization") + + # Analyze the GitHub App + print("\n📊 Analyzing app...") + token_manager.analyze_app(config) + + except Exception as error: + formatter.print_error(f"Operation failed: {error}") + +if __name__ == "__main__": + main() +``` + +### CI/CD Integration Example + +```python +#!/usr/bin/env python3 +"""CI/CD pipeline integration using the SDK.""" + +import os +import sys +from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient +from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config + +def get_github_token_for_pipeline(): + """Generate GitHub token for CI/CD pipeline.""" + + # Create config from CI environment variables + config = Config( + app_id=os.getenv("GITHUB_APP_ID"), + private_key_content=os.getenv("GITHUB_PRIVATE_KEY"), # Base64 decoded in CI + timeout=30 + ) + + if not config.has_required_config: + print("❌ Missing required GitHub App configuration", file=sys.stderr) + sys.exit(1) + + try: + # Use the built-in helper for private key handling + private_key = GitHubAppAuth.get_private_key_content( + config.private_key_path, + config.private_key_content + ) + + # Generate JWT and access token + auth = GitHubAppAuth(config.app_id, private_key) + api_client = GitHubAPIClient() + + jwt_token = auth.generate_jwt() + + # Get installation ID from environment or find by organization + installation_id = os.getenv("GITHUB_INSTALLATION_ID") + if installation_id: + access_token = api_client.get_installation_access_token(jwt_token, int(installation_id)) + else: + # Find installation by organization + org_name = os.getenv("GITHUB_ORGANIZATION") + installations = api_client.list_installations(jwt_token) + + for installation in installations: + if installation.get("account", {}).get("login") == org_name: + installation_id = installation.get("id") + access_token = api_client.get_installation_access_token(jwt_token, installation_id) + break + else: + print(f"❌ No installation found for organization: {org_name}", file=sys.stderr) + sys.exit(1) + + # Output token for pipeline use + print(access_token) + return access_token + + except Exception as e: + print(f"❌ Failed to generate token: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + get_github_token_for_pipeline() +``` + +### Advanced Configuration Example + +```python +from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter +from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config + +# Advanced configuration with all options +config = Config( + app_id="YOUR_APP_ID", + private_key_path="/path/to/private-key.pem", + timeout=60, + debug=True +) + +# Initialize components with custom settings +api_client = GitHubAPIClient(timeout=60) +formatter = OutputFormatter(use_colors=False) # For CI/scripts +token_manager = TokenManager(api_client, formatter) + +# Perform multiple operations +token_manager.list_installations(config) +token_manager.analyze_app(config) +token_manager.generate_org_token(config, "your-organization") +``` + ## 🔧 Troubleshooting ### Common Issues @@ -436,7 +631,6 @@ github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --priv ```bash # Check app ID and private key github-app-token-generator --debug --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem - ``` #### 4. **"Token is invalid or expired"** @@ -473,8 +667,11 @@ The tool automatically creates log files: Make sure you're in the virtual environment: ```bash +# Activate virtual environment +source venv/bin/activate -pip install git+https://github.com/opencpk/cpk-lib-python-github.git +# Install package +pip install -e . # Now try running the command again github-app-token-generator @@ -503,6 +700,7 @@ For support and questions: - ✅ GitHub App Token Generator - ✅ CLI interface with rich output - ✅ Comprehensive token management +- ✅ Python SDK for programmatic access ### Upcoming (v2.x) - 🔄 Repository bulk operations @@ -519,3 +717,5 @@ For support and questions: --- **Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** + +*Empowering development teams with powerful GitHub automation tools.* diff --git a/cpk_lib_python_github/__init__.py b/cpk_lib_python_github/__init__.py index 37514df..c0fe8b1 100644 --- a/cpk_lib_python_github/__init__.py +++ b/cpk_lib_python_github/__init__.py @@ -1,16 +1,21 @@ # -*- coding: utf-8 -*- """GitHub App token generator package.""" -from .github_app_token_generator_package.github_app_token_generator.main import ( - generate_jwt, - get_installation_access_token, - list_installations, - revoke_installation_token, - validate_token, +from .github_app_token_generator_package.github_app_token_generator import ( + auth, + formatters, + github_api, + token_manager, ) +# Import the classes from modules +GitHubAppAuth = auth.GitHubAppAuth +OutputFormatter = formatters.OutputFormatter +GitHubAPIClient = github_api.GitHubAPIClient +TokenManager = token_manager.TokenManager + __all__ = [ - "get_installation_access_token", - "list_installations", - "validate_token", - "revoke_installation_token", + "GitHubAppAuth", + "GitHubAPIClient", + "TokenManager", + "OutputFormatter", ] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py index 7b2b516..755ed1f 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py @@ -1,17 +1,18 @@ # -*- coding: utf-8 -*- """GitHub App token generator module.""" -from .main import ( - generate_jwt, - get_installation_access_token, - list_installations, - revoke_installation_token, - validate_token, -) +from .auth import GitHubAppAuth +from .config import Config, get_config_from_env +from .formatters import OutputFormatter +from .github_api import GitHubAPIClient +from .main import main +from .token_manager import TokenManager __all__ = [ - "generate_jwt", - "get_installation_access_token", - "list_installations", - "validate_token", - "revoke_installation_token", + "main", + "GitHubAppAuth", + "GitHubAPIClient", + "TokenManager", + "OutputFormatter", + "Config", + "get_config_from_env", ] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py new file mode 100644 index 0000000..ee70026 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""GitHub App authentication utilities.""" +import logging +import time + +import jwt + +logger = logging.getLogger(__name__) + + +class GitHubAppAuth: + """Handle GitHub App authentication.""" + + def __init__(self, app_id: str, private_key: str): + self.app_id = app_id + self.private_key = private_key + + def generate_jwt(self) -> str: + """Generate JWT token for GitHub App.""" + try: + payload = { + "iat": int(time.time()), + "exp": int(time.time()) + (10 * 60), # 10 minutes expiration + "iss": self.app_id, + } + token = jwt.encode(payload, self.private_key, algorithm="RS256") + logger.debug("Successfully generated JWT for app ID: %s", self.app_id) + return token + except Exception as error: + logger.error("Failed to generate JWT: %s", error) + raise ValueError(f"Failed to generate JWT: {error}") from error + + @staticmethod + def read_private_key(private_key_path: str) -> str: + """Read private key from file.""" + try: + with open(private_key_path, "r", encoding="utf-8") as key_file: + content = key_file.read() + logger.debug("Successfully read private key from: %s", private_key_path) + return content + except FileNotFoundError as error: + logger.error("Private key file not found: %s", private_key_path) + raise error + except IOError as error: + logger.error("Error reading private key file: %s", error) + raise error + + @staticmethod + def get_private_key_content(private_key_path=None, private_key_content=None) -> str: + """Get private key content from file or direct input.""" + if private_key_path and private_key_content: + raise ValueError("Cannot specify both --private-key-path and --private-key") + + if not private_key_path and not private_key_content: + raise ValueError("Must specify either --private-key-path or --private-key") + + if private_key_content: + logger.debug("Using private key content provided directly") + return private_key_content + + if private_key_path: + logger.debug("Reading private key from file: %s", private_key_path) + return GitHubAppAuth.read_private_key(private_key_path) + + raise ValueError("No private key source provided") diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py new file mode 100644 index 0000000..e8916fe --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""Command line interface for GitHub App Token Generator.""" +import argparse + +from colorama import Fore, Style + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure argument parser.""" + parser = argparse.ArgumentParser( + description=( + f"{Fore.GREEN}{Style.BRIGHT}Generate and manage GitHub App " + f"installation tokens{Style.RESET_ALL}" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f""" +{Fore.GREEN}{Style.BRIGHT}Examples:{Style.RESET_ALL} + {Fore.CYAN}# Generate tokens (using private key file){Style.RESET_ALL} + {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--org{Style.RESET_ALL} myorg \\ + {Fore.CYAN}--app-id{Style.RESET_ALL} APP_ID \\ + {Fore.CYAN}--private-key-path{Style.RESET_ALL} /path/to/key.pem + + {Fore.CYAN}# Generate tokens (using private key content){Style.RESET_ALL} + {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--org{Style.RESET_ALL} myorg \\ + {Fore.CYAN}--app-id{Style.RESET_ALL} APP_ID \\ + {Fore.CYAN}--private-key{Style.RESET_ALL} "$(cat /path/to/key.pem)" + +{Fore.BLUE}{Style.BRIGHT}Environment Variables:{Style.RESET_ALL} + {Fore.MAGENTA}APP_ID{Style.RESET_ALL} GitHub App ID + {Fore.MAGENTA}PRIVATE_KEY_PATH{Style.RESET_ALL} Path to private key file + {Fore.MAGENTA}PRIVATE_KEY{Style.RESET_ALL} Private key content directly + """, + ) + + # Add arguments + parser.add_argument( + "--app-id", help="GitHub App ID (or set APP_ID env var)", metavar="ID" + ) + + # Private key group + key_group = parser.add_mutually_exclusive_group() + key_group.add_argument( + "--private-key-path", help="Path to private key file", metavar="PATH" + ) + key_group.add_argument( + "--private-key", help="Private key content directly", metavar="KEY_CONTENT" + ) + + # Operations + parser.add_argument( + "--org", help="Organization name to get token for", metavar="ORG" + ) + parser.add_argument( + "--installation-id", help="Installation ID (if known)", metavar="ID" + ) + parser.add_argument( + "--list-installations", action="store_true", help="List all installations" + ) + parser.add_argument( + "--analyze-app", action="store_true", help="Analyze GitHub App details" + ) + parser.add_argument( + "--validate-token", help="Validate an existing token", metavar="TOKEN" + ) + parser.add_argument( + "--revoke-token", help="Revoke an existing token", metavar="TOKEN" + ) + parser.add_argument( + "--force", action="store_true", help="Skip confirmation prompts" + ) + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + + return parser + + +def print_banner(): + """Print colorful banner.""" + banner = f""" +{Fore.CYAN}{Style.BRIGHT} +╔══════════════════════════════════════════════════════════╗ +║ 🔑 GitHub App Token Generator ║ +║ GitHub Tools ║ +╚══════════════════════════════════════════════════════════╝ +{Style.RESET_ALL} +{Fore.GREEN}A powerful CLI tool for generating, managing,{Style.RESET_ALL} +{Fore.GREEN}and analyzing GitHub App tokens{Style.RESET_ALL} +""" + print(banner) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py new file mode 100644 index 0000000..b1f7187 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""Configuration management for GitHub App Token Generator.""" +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import toml # Moved from line 158 to top level + +logger = logging.getLogger(__name__) + + +@dataclass +class Config: + """Configuration container for the application.""" + + app_id: Optional[str] = None + private_key_path: Optional[str] = None + private_key_content: Optional[str] = None + timeout: int = 30 + debug: bool = False + + def __post_init__(self): + """Validate configuration after initialization.""" + if self.app_id: + try: + # Ensure app_id is a valid integer + int(self.app_id) + except ValueError as error: + raise ValueError( + f"Invalid app_id: {self.app_id}. Must be a number." + ) from error + + @property + def has_private_key(self) -> bool: + """Check if private key is configured.""" + return bool(self.private_key_path or self.private_key_content) + + @property + def has_required_config(self) -> bool: + """Check if all required configuration is present.""" + return bool(self.app_id and self.has_private_key) + + +def requires_app_config(args) -> bool: + """Check if the operation requires App ID and private key.""" + # Operations that work with tokens only + token_only_operations = [ + args.validate_token, + args.revoke_token, + ] + + # If any token-only operation is specified, we don't need app config + if any(token_only_operations): + return False + + # All other operations require app config + return True + + +def get_config_from_env(args) -> Config: + """Get configuration from environment variables and command line arguments.""" + config = Config() + + # Check if this operation requires app configuration + needs_app_config = requires_app_config(args) + + # App ID - CLI args take precedence over env vars + config.app_id = args.app_id or os.getenv("APP_ID") + if needs_app_config and not config.app_id: + logger.error( + "App ID is required for this operation. " + "Set APP_ID environment variable or use --app-id" + ) + raise ValueError( + "App ID is required for this operation. " + "Set APP_ID environment variable or use --app-id" + ) + + # Private key configuration + config.private_key_path = args.private_key_path or os.getenv("PRIVATE_KEY_PATH") + config.private_key_content = args.private_key or os.getenv("PRIVATE_KEY") + + if needs_app_config and not config.has_private_key: + logger.error( + "Private key is required for this operation. " + "Set PRIVATE_KEY or PRIVATE_KEY_PATH environment variable " + "or use --private-key/--private-key-path" + ) + raise ValueError( + "Private key is required for this operation. " + "Set PRIVATE_KEY or PRIVATE_KEY_PATH environment variable " + "or use --private-key/--private-key-path" + ) + + # Optional configuration + config.debug = args.debug if hasattr(args, "debug") else False + config.timeout = int(os.getenv("TIMEOUT", "30")) + + logger.debug("Configuration loaded successfully") + if config.app_id: + logger.debug("App ID: %s", config.app_id) + logger.debug( + "Private key source: %s", + "content" if config.private_key_content else "file", + ) + else: + logger.debug("Operating in token-only mode") + logger.debug("Timeout: %s seconds", config.timeout) + + return config + + +def get_environment_info() -> Dict[str, Any]: + """Get environment information for debugging.""" + return { + "app_id_set": bool(os.getenv("APP_ID")), + "private_key_path_set": bool(os.getenv("PRIVATE_KEY_PATH")), + "private_key_content_set": bool(os.getenv("PRIVATE_KEY")), + "timeout": os.getenv("TIMEOUT", "30"), + "python_version": os.sys.version, + "platform": os.sys.platform, + } + + +def validate_environment() -> bool: + """Validate that the environment has minimum required configuration.""" + try: + app_id = os.getenv("APP_ID") + private_key_path = os.getenv("PRIVATE_KEY_PATH") + private_key_content = os.getenv("PRIVATE_KEY") + + if not app_id: + logger.warning("APP_ID environment variable not set") + return False + + if not (private_key_path or private_key_content): + logger.warning( + "Neither PRIVATE_KEY_PATH nor PRIVATE_KEY environment variable set" + ) + return False + + # Validate app_id is a number + try: + int(app_id) + except ValueError: + logger.error("APP_ID must be a valid number, got: %s", app_id) + return False + + # If private_key_path is set, check if file exists + if private_key_path and not os.path.isfile(private_key_path): + logger.error("Private key file not found: %s", private_key_path) + return False + + logger.info("Environment validation successful") + return True + + except Exception as error: + logger.error("Environment validation failed: %s", error) + return False + + +def load_config_file(config_path: str) -> Dict[str, Any]: + """Load configuration from a file (for future use).""" + try: + with open(config_path, "r", encoding="utf-8") as config_file: + config_data = toml.load(config_file) + logger.info("Configuration loaded from file: %s", config_path) + return config_data + except FileNotFoundError: + logger.warning("Configuration file not found: %s", config_path) + return {} + except Exception as error: + logger.error("Error loading configuration file: %s", error) + return {} diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py new file mode 100644 index 0000000..8528b3e --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +"""Output formatting utilities.""" +import logging +from typing import Any, Dict, List + +from colorama import Fore, Style, init + +# Initialize colorama +init(autoreset=True) + +logger = logging.getLogger(__name__) + + +class OutputFormatter: + """Handle formatted output for the CLI.""" + + def __init__(self, use_colors: bool = True): + self.use_colors = use_colors + + def print_success(self, message: str): + """Print success message with green color.""" + if self.use_colors: + print(f"{Fore.GREEN}✅ {message}{Style.RESET_ALL}") + else: + print(f"✅ {message}") + + def print_error(self, message: str): + """Print error message with red color.""" + if self.use_colors: + print(f"{Fore.RED}❌ {message}{Style.RESET_ALL}") + else: + print(f"❌ {message}") + + def print_warning(self, message: str): + """Print warning message with yellow color.""" + if self.use_colors: + print(f"{Fore.YELLOW}âš ī¸ {message}{Style.RESET_ALL}") + else: + print(f"âš ī¸ {message}") + + def print_info(self, message: str): + """Print info message with blue color.""" + if self.use_colors: + print(f"{Fore.CYAN}â„šī¸ {message}{Style.RESET_ALL}") + else: + print(f"â„šī¸ {message}") + + def print_token(self, token: str): + """Print token with highlighting.""" + if self.use_colors: + print(f"{Fore.GREEN}{Style.BRIGHT}{token}{Style.RESET_ALL}") + else: + print(token) + + def format_installations_table(self, installations: List[Dict[str, Any]]) -> str: + """Format installations as a table.""" + if not installations: + return f"{Fore.YELLOW}No installations found{Style.RESET_ALL}" + + lines = [ + "", + f"{Fore.CYAN}{Style.BRIGHT}=== Available GitHub ", + f"App Installations ==={Style.RESET_ALL}", + "", + f"{Fore.BLUE}{'Installation ID':<20}{Style.RESET_ALL}" + f" | {Fore.BLUE}{'Account':<20}{Style.RESET_ALL}" + f" | {Fore.BLUE}{'Target Type':<15}{Style.RESET_ALL}", + f"{'-' * 60}", + ] + + # Add rows + for installation in installations: + install_id = str(installation.get("id", "N/A")) + account = installation.get("account", {}).get("login", "N/A") + target_type = installation.get("target_type", "N/A") + + if self.use_colors: + row = ( + f"{Fore.YELLOW}{install_id:<20}{Style.RESET_ALL} | " + f"{Fore.GREEN}{account:<20}{Style.RESET_ALL} | " + f"{target_type:<15}" + ) + else: + row = f"{install_id:<20} | {account:<20} | {target_type:<15}" + + lines.append(row) + + # Add footer + count = len(installations) + lines.extend( + [ + "", + f"{Fore.CYAN}â„šī¸ Found {count} installation(s){Style.RESET_ALL}", + f"{Fore.BLUE}💡 Use --org or{Style.RESET_ALL}", + f"{Fore.BLUE} --installation-id to get a token{Style.RESET_ALL}", + ] + ) + + return "\n".join(lines) + + def format_token_validation(self, validation_result: Dict[str, Any]) -> str: + """Format token validation results.""" + lines = [] + + if validation_result.get("valid"): + lines.append(f"{Fore.GREEN}✅ Token is valid{Style.RESET_ALL}") + lines.append(f"Type: {validation_result.get('type', 'Unknown')}") + + if "repositories_count" in validation_result: + count = validation_result["repositories_count"] + lines.append( + f"Accessible repositories: {Fore.CYAN}{count}{Style.RESET_ALL}" + ) + + if "rate_limit" in validation_result: + rate_limit = validation_result["rate_limit"] + remaining = rate_limit.get("remaining", "Unknown") + limit = rate_limit.get("limit", "Unknown") + lines.append( + f"Rate limit: {Fore.YELLOW}{remaining}/{limit}{Style.RESET_ALL}" + ) + + if "scopes" in validation_result and validation_result["scopes"]: + scopes = ", ".join(validation_result["scopes"]) + lines.append(f"Scopes: {Fore.MAGENTA}{scopes}{Style.RESET_ALL}") + else: + lines.append(f"{Fore.RED}❌ Token is invalid{Style.RESET_ALL}") + if "reason" in validation_result: + lines.append(f"Reason: {validation_result['reason']}") + if "error" in validation_result: + lines.append(f"Error: {validation_result['error']}") + + return "\n".join(lines) + + def _format_basic_app_info(self, app_info: Dict[str, Any]) -> str: + """Format basic app information section.""" + lines = [ + f"{Fore.GREEN}{Style.BRIGHT}🤖 App Information{Style.RESET_ALL}", + f" ID: {Fore.YELLOW}{app_info.get('id', 'Unknown')}{Style.RESET_ALL}", + f" Name: {Fore.GREEN}{app_info.get('name', 'Unknown')}{Style.RESET_ALL}", + f" Slug: {Fore.CYAN}{app_info.get('slug', 'Unknown')}{Style.RESET_ALL}", + f" Description: {app_info.get('description', 'No description')}", + ] + + owner = app_info.get("owner", {}) + lines.extend( + [ + f" Owner: {Fore.MAGENTA}", + f"{owner.get('login', 'Unknown')}{Style.RESET_ALL}", + f" Owner Type: {owner.get('type', 'Unknown')}", + f" URL: {Fore.BLUE}" + f"{app_info.get('html_url', 'Unknown')}{Style.RESET_ALL}", + f" Created: {Fore.BLUE}", + f"{app_info.get('created_at', 'Unknown')}{Style.RESET_ALL}", + "", # Empty line + ] + ) + + return "\n".join(lines) + + def format_app_analysis( + self, + app_info: Dict[str, Any], + installations: List[Dict[str, Any]], + installation_repos: Dict[int, Dict[str, Any]], + ) -> str: + """Format comprehensive GitHub App analysis.""" + lines = [ + f"{Fore.CYAN}{Style.BRIGHT}=== GitHub App Analysis ==={Style.RESET_ALL}", + "", + ] + + # Basic app info + lines.append(self._format_basic_app_info(app_info)) + + # Installation summary + lines.extend( + [ + f"{Fore.BLUE}{Style.BRIGHT}📍 Installation Summary{Style.RESET_ALL}", + f" Total Installations: {Fore.YELLOW}", + f"{len(installations)}{Style.RESET_ALL}", + ] + ) + + total_repos = sum( + repos.get("total_count", 0) for repos in installation_repos.values() + ) + + lines.extend( + [ + f" Total Repositories: {Fore.YELLOW}{total_repos}{Style.RESET_ALL}", + " Installed On:", + ] + ) + + for installation in installations: + account = installation.get("account", {}).get("login", "Unknown") + target_type = installation.get("target_type", "Unknown") + created_at = installation.get("created_at", "") + created = created_at[:10] if created_at else "Unknown" + lines.append( + f" {Fore.GREEN}✅{Style.RESET_ALL} " + f"{Fore.CYAN}{account}{Style.RESET_ALL} ({target_type}) - {created}" + ) + + lines.append("") # Empty line + + # Permissions + if "permissions" in app_info: + lines.append(self._format_permissions(app_info["permissions"])) + + # Events + if "events" in app_info: + lines.append(self._format_events(app_info["events"])) + + # Repository details + if installation_repos: + lines.append( + self._format_repo_details( + installations, installation_repos, total_repos + ) + ) + + return "\n".join(lines) + + def _format_permissions(self, permissions: Dict[str, str]) -> str: + """Format permissions section.""" + lines = [f"{Fore.MAGENTA}{Style.BRIGHT}🔐 App Permissions{Style.RESET_ALL}"] + + for perm, level in permissions.items(): + if level == "write": + color = Fore.RED + icon = "âœī¸" + elif level == "read": + color = Fore.GREEN + icon = "đŸ‘ī¸" + else: + color = Fore.YELLOW + icon = "🔧" + + lines.append(f" {icon} {perm}: {color}{level}{Style.RESET_ALL}") + + lines.append("") # Empty line + return "\n".join(lines) + + def _format_events(self, events: List[str]) -> str: + """Format events section.""" + lines = [f"{Fore.BLUE}{Style.BRIGHT}📡 Subscribed Events{Style.RESET_ALL}"] + + for event in events: + lines.append(f" 📨 {Fore.CYAN}{event}{Style.RESET_ALL}") + + lines.append("") # Empty line + return "\n".join(lines) + + def _format_repo_details( + self, + installations: List[Dict[str, Any]], + installation_repos: Dict[int, Dict[str, Any]], + total_repos: int, + ) -> str: + """Format repository details section.""" + lines = [ + f"{Fore.GREEN}{Style.BRIGHT}📚 Accessible Repositories " + f"({total_repos} total){Style.RESET_ALL}" + ] + + threshold = 100 + + for installation in installations: + install_id = installation.get("id") + account = installation.get("account", {}).get("login", "Unknown") + + if install_id in installation_repos: + repos = installation_repos[install_id] + repo_count = repos.get("total_count", 0) + + # Add installation header + lines.append(f" {Fore.CYAN}{account}:{Style.RESET_ALL}") + + # Add repository lines + for repo in repos.get("repositories", [])[:threshold]: + repo_name = repo.get("full_name", "Unknown") + lines.append(f" â€ĸ {repo_name}") + + # Add "more repositories" line if needed + if repo_count > threshold: + lines.append( + f" ... and {repo_count - threshold} more repositories" + ) + + return "\n".join(lines) + + def print_progress(self, message: str, success: bool = True): + """Print progress indicator.""" + if success: + if self.use_colors: + print(f"{Fore.GREEN}✓{Style.RESET_ALL} {message}") + else: + print(f"✓ {message}") + else: + if self.use_colors: + print(f"{Fore.RED}✗{Style.RESET_ALL} {message}") + else: + print(f"✗ {message}") + + def confirm_action(self, message: str, force: bool = False) -> bool: + """Ask for user confirmation.""" + if force: + self.print_info(f"Force mode enabled - {message}") + return True + + response = input(f"{Fore.YELLOW}âš ī¸ {message} (y/N): {Style.RESET_ALL}") + return response.lower() in ["y", "yes"] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py new file mode 100644 index 0000000..1fbbd8e --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +"""GitHub API client.""" +import logging +from typing import Any, Dict, List + +import requests + +logger = logging.getLogger(__name__) + + +class GitHubAPIClient: + """GitHub API client for GitHub App operations.""" + + BASE_URL = "https://api.github.com" + + def __init__(self, timeout: int = 30): + self.timeout = timeout + + def get_installation_access_token( + self, jwt_token: str, installation_id: int + ) -> str: + """Get installation access token.""" + url = f"{self.BASE_URL}/app/installations/{installation_id}/access_tokens" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug( + "Requesting access token for installation ID: %s", installation_id + ) + response = requests.post(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + token_data = response.json() + if "token" not in token_data: + raise ValueError("Invalid response: token not found in response") + + logger.info( + "Successfully obtained access token for installation: %s", + installation_id, + ) + return token_data["token"] + + except requests.exceptions.HTTPError as http_error: + logger.error( + "HTTP error occurred while getting access token: %s", http_error + ) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error( + "Request error occurred while getting access token: %s", req_error + ) + raise req_error + + def list_installations(self, jwt_token: str) -> List[Dict[str, Any]]: + """List GitHub App installations.""" + url = f"{self.BASE_URL}/app/installations" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Fetching GitHub App installations") + response = requests.get(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + installations = response.json() + logger.info("Found %d installations", len(installations)) + return installations + + except requests.exceptions.HTTPError as http_error: + logger.error( + "HTTP error occurred while listing installations: %s", http_error + ) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error( + "Request error occurred while listing installations: %s", req_error + ) + raise req_error + + def validate_token(self, token: str) -> Dict[str, Any]: + """Validate a GitHub token.""" + url = f"{self.BASE_URL}/installation/repositories" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Validating GitHub App installation token") + response = requests.get(url, headers=headers, timeout=self.timeout) + + if response.status_code == 200: + repo_info = response.json() + scopes_header = response.headers.get("X-OAuth-Scopes", "") + scopes = scopes_header.split(", ") if scopes_header else [] + + rate_limit = { + "remaining": response.headers.get("X-RateLimit-Remaining"), + "limit": response.headers.get("X-RateLimit-Limit"), + "reset": response.headers.get("X-RateLimit-Reset"), + } + + return { + "valid": True, + "type": "GitHub App Installation Token", + "repositories_count": repo_info.get("total_count", 0), + "scopes": scopes, + "rate_limit": rate_limit, + } + + # Handle error cases + error_messages = { + 401: "Invalid or expired token", + 403: "Insufficient permissions", + } + + return { + "valid": False, + "status_code": response.status_code, + "reason": error_messages.get( + response.status_code, f"HTTP {response.status_code}" + ), + } + + except requests.exceptions.RequestException as req_error: + logger.error("Error validating token: %s", req_error) + return {"valid": False, "error": str(req_error)} + + def revoke_installation_token(self, token: str) -> bool: + """Revoke an installation access token.""" + url = f"{self.BASE_URL}/installation/token" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Attempting to revoke installation token") + response = requests.delete(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + logger.info("Successfully revoked installation token") + return True + + except requests.exceptions.HTTPError as http_error: + if response.status_code == 404: + logger.warning("Token not found or already revoked") + return False + logger.error("HTTP error occurred while revoking token: %s", http_error) + raise http_error + except requests.exceptions.RequestException as req_error: + logger.error("Request error occurred while revoking token: %s", req_error) + raise req_error + + def get_app_info(self, jwt_token: str) -> Dict[str, Any]: + """Get GitHub App information.""" + url = f"{self.BASE_URL}/app" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Fetching GitHub App information") + response = requests.get(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + app_info = response.json() + logger.info("Successfully retrieved app information") + return app_info + + except requests.exceptions.RequestException as error: + logger.error("Error fetching app info: %s", error) + raise error + + def get_installation_repositories( + self, jwt_token: str, installation_id: int + ) -> Dict[str, Any]: + """Get repositories accessible by installation.""" + try: + # First get an installation access token + installation_token = self.get_installation_access_token( + jwt_token, installation_id + ) + + # Use the installation token to get repositories + url = f"{self.BASE_URL}/installation/repositories" + headers = { + "Authorization": f"token {installation_token}", + "Accept": "application/vnd.github.v3+json", + } + + logger.debug("Fetching repositories for installation: %s", installation_id) + response = requests.get(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + return response.json() + + except requests.exceptions.RequestException as error: + logger.error("Error fetching installation repositories: %s", error) + # Return empty result instead of raising + return {"total_count": 0, "repositories": [], "error": str(error)} + + def get_accessible_repositories_via_token( + self, installation_token: str + ) -> Dict[str, Any]: + """Get repositories using installation token instead of JWT.""" + url = f"{self.BASE_URL}/installation/repositories" + headers = { + "Authorization": f"token {installation_token}", + "Accept": "application/vnd.github.v3+json", + } + + try: + logger.debug("Fetching repositories via installation token") + response = requests.get(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as error: + logger.error("Error fetching repositories via token: %s", error) + return {"total_count": 0, "repositories": [], "error": str(error)} diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py index 5650156..4c172f3 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -1,19 +1,15 @@ # -*- coding: utf-8 -*- -""" -GitHub App Token Generator - -This module provides functionality to generate GitHub App installation tokens -for CLI use and automation scripts. -""" -import argparse +"""GitHub App Token Generator - Main entry point.""" import logging -import os import sys -import time -import jwt -import requests -from colorama import Fore, Style, init +from colorama import init + +from .cli import create_parser, print_banner +from .config import get_config_from_env +from .formatters import OutputFormatter +from .github_api import GitHubAPIClient +from .token_manager import TokenManager # Initialize colorama init(autoreset=True) @@ -28,1078 +24,54 @@ logger = logging.getLogger(__name__) -def read_private_key(private_key_path): - """ - Read the private key from a file. - - Args: - private_key_path (str): The path to the private key file. - - Returns: - str: The content of the private key file. - - Raises: - FileNotFoundError: If the private key file is not found. - IOError: If there's an error reading the file. - """ - try: - with open(private_key_path, "r", encoding="utf-8") as key_file: - content = key_file.read() - logger.debug("Successfully read private key from: %s", private_key_path) - return content - except FileNotFoundError as error: - logger.error("Private key file not found: %s", private_key_path) - raise error - except IOError as error: - logger.error("Error reading private key file: %s", error) - raise error - - -def get_private_key_content(private_key_path=None, private_key_content=None): - """ - Get private key content from either file path or direct content. - - Args: - private_key_path (str, optional): Path to private key file. - private_key_content (str, optional): Direct private key content. - - Returns: - str: The private key content. - - Raises: - ValueError: If neither or both options are provided. - FileNotFoundError: If the private key file is not found. - IOError: If there's an error reading the file. - """ - if private_key_path and private_key_content: - raise ValueError("Cannot specify both --private-key-path and --private-key") - - if not private_key_path and not private_key_content: - raise ValueError("Must specify either --private-key-path or --private-key") - - if private_key_content: - logger.debug("Using private key content provided directly") - return private_key_content - - if private_key_path: - logger.debug("Reading private key from file: %s", private_key_path) - return read_private_key(private_key_path) - - # This shouldn't be reached, but just in case - raise ValueError("No private key source provided") - - -def generate_jwt(app_id, private_key): - """ - Generate a JWT for the GitHub App to provide the app identity to Github - (will be used to generate installation token). - - Args: - app_id (str): The GitHub App ID. - private_key (str): The private key for the GitHub App. - - Returns: - str: The generated JWT. - - Raises: - ValueError: If the JWT generation fails. - """ - try: - payload = { - "iat": int(time.time()), - "exp": int(time.time()) + (10 * 60), # JWT expiration time (10 minutes) - "iss": app_id, - } - token = jwt.encode(payload, private_key, algorithm="RS256") - logger.debug("Successfully generated JWT for app ID: %s", app_id) - return token - except Exception as error: - logger.error("Failed to generate JWT: %s", error) - raise ValueError(f"Failed to generate JWT: {error}") from error - - -def get_installation_access_token(jwt_token, installation_id): - """ - Get an installation access token for the GitHub App. - - Args: - jwt_token (str): The JWT for the GitHub App. - installation_id (int): The installation ID of the GitHub App. - - Returns: - str: The installation access token. - - Raises: - requests.exceptions.HTTPError: If an HTTP error occurs. - requests.exceptions.RequestException: If a request error occurs. - ValueError: If the response is invalid. - """ - url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" - headers = { - "Authorization": f"Bearer {jwt_token}", - "Accept": "application/vnd.github.v3+json", - } - - try: - logger.debug("Requesting access token for installation ID: %s", installation_id) - response = requests.post(url, headers=headers, timeout=30) - response.raise_for_status() - - token_data = response.json() - if "token" not in token_data: - raise ValueError("Invalid response: token not found in response") - - logger.info( - "Successfully obtained access token for installation: %s", installation_id - ) - return token_data["token"] - - except requests.exceptions.HTTPError as http_error: - logger.error("HTTP error occurred while getting access token: %s", http_error) - raise http_error - except requests.exceptions.RequestException as req_error: - logger.error("Request error occurred while getting access token: %s", req_error) - raise req_error - except ValueError as val_error: - logger.error("Invalid response format: %s", val_error) - raise val_error - - -def list_installations(jwt_token): - """ - List installations of the GitHub App - (which org the app is installed with other details such as installation id). - - Args: - jwt_token (str): The JWT for the GitHub App. - - Returns: - list: A list of installations. - - Raises: - requests.exceptions.HTTPError: If an HTTP error occurs. - requests.exceptions.RequestException: If a request error occurs. - """ - url = "https://api.github.com/app/installations" - headers = { - "Authorization": f"Bearer {jwt_token}", - "Accept": "application/vnd.github.v3+json", - } - - try: - logger.debug("Fetching GitHub App installations") - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - - installations = response.json() - logger.info("Found %d installations", len(installations)) - return installations - - except requests.exceptions.HTTPError as http_error: - logger.error("HTTP error occurred while listing installations: %s", http_error) - raise http_error - except requests.exceptions.RequestException as req_error: - logger.error( - "Request error occurred while listing installations: %s", req_error - ) - raise req_error - - -def revoke_installation_token(token): - """ - Revoke an installation access token. - - Args: - token (str): The installation access token to revoke. - - Returns: - bool: True if revocation was successful, False otherwise. - - Raises: - requests.exceptions.HTTPError: If an HTTP error occurs. - requests.exceptions.RequestException: If a request error occurs. - """ - url = "https://api.github.com/installation/token" - headers = { - "Authorization": f"token {token}", - "Accept": "application/vnd.github.v3+json", - } - - try: - logger.debug("Attempting to revoke installation token") - response = requests.delete(url, headers=headers, timeout=30) - response.raise_for_status() - - logger.info("Successfully revoked installation token") - return True - - except requests.exceptions.HTTPError as http_error: - if response.status_code == 404: - logger.warning("Token not found or already revoked") - return False - logger.error("HTTP error occurred while revoking token: %s", http_error) - raise http_error - except requests.exceptions.RequestException as req_error: - logger.error("Request error occurred while revoking token: %s", req_error) - raise req_error - - -def validate_token(token): - """ - Validate if a token is still active by checking installation info. - - Args: - token (str): The token to validate. - - Returns: - dict: Token info if valid, error info if invalid. - """ - url = "https://api.github.com/installation/repositories" - headers = { - "Authorization": f"token {token}", - "Accept": "application/vnd.github.v3+json", - } - - try: - logger.debug("Validating GitHub App installation token") - response = requests.get(url, headers=headers, timeout=30) - - if response.status_code == 200: - repo_info = response.json() - scopes_header = response.headers.get("X-OAuth-Scopes", "") - scopes = scopes_header.split(", ") if scopes_header else [] - - rate_limit = { - "remaining": response.headers.get("X-RateLimit-Remaining"), - "limit": response.headers.get("X-RateLimit-Limit"), - "reset": response.headers.get("X-RateLimit-Reset"), - } - - return { - "valid": True, - "type": "GitHub App Installation Token", - "repositories_count": repo_info.get("total_count", 0), - "scopes": scopes, - "rate_limit": rate_limit, - } - if response.status_code == 401: - logger.info("Token validation failed: 401 - Token is invalid or expired") - return { - "valid": False, - "status_code": response.status_code, - "reason": "Invalid or expired token", - } - if response.status_code == 403: - logger.info("Token validation failed: 403 - Insufficient permissions") - return { - "valid": False, - "status_code": response.status_code, - "reason": "Insufficient permissions", - } - - logger.info("Token validation failed: %s", response.status_code) - return {"valid": False, "status_code": response.status_code} - - except requests.exceptions.RequestException as req_error: - logger.error("Error validating token: %s", req_error) - return {"valid": False, "error": str(req_error)} - - -def _fetch_installation_repos(jwt_token, installation): - """ - Helper function to fetch repositories for a single installation. - - Args: - jwt_token (str): JWT token for the GitHub App - installation (dict): Installation information - - Returns: - tuple: (repos_list, repo_count, permissions_set, events_set) - """ - installation_id = installation["id"] - result_data = { - "repos": [], - "repo_count": 0, - "permissions_set": set(), - "events_set": set(), - } - - try: - install_token = get_installation_access_token(jwt_token, installation_id) - - # Get repositories for this installation - repo_response = requests.get( - "https://api.github.com/installation/repositories", - headers={ - "Authorization": f"token {install_token}", - "Accept": "application/vnd.github.v3+json", - }, - timeout=30, - ) - - if repo_response.status_code == 200: - repo_data = repo_response.json() - result_data["repo_count"] = repo_data.get("total_count", 0) - - # Add ALL repositories (just names and installation) - for repo in repo_data.get("repositories", []): - result_data["repos"].append( - { - "name": repo["full_name"], - "installation": installation["account"]["login"], - } - ) - - # Track permissions and events - result_data["permissions_set"].update( - installation.get("permissions", {}).keys() - ) - result_data["events_set"].update(installation.get("events", [])) - - except Exception as e: - logger.warning( - "Could not fetch repos for installation %s: %s", - installation_id, - e, - ) - - return ( - result_data["repos"], - result_data["repo_count"], - result_data["permissions_set"], - result_data["events_set"], - ) - - -def get_github_app_details(jwt_token): - """ - Get detailed information about the GitHub App. - - Args: - jwt_token (str): JWT token for the GitHub App - - Returns: - dict: Comprehensive app information - """ - logger.info("Fetching GitHub App details") - - headers = { - "Authorization": f"Bearer {jwt_token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "GitHub-App-Token-Generator/1.2.0", - } - - app_details = { - "valid": False, - "app_info": None, - "installations": [], - "total_repositories": 0, - "repository_sample": [], - "permissions_summary": {}, - "events_summary": [], - "error": None, - } - - try: - # 1. Get the GitHub App information - app_response = requests.get( - "https://api.github.com/app", headers=headers, timeout=30 - ) - - if app_response.status_code == 200: - app_details["valid"] = True - app_data = app_response.json() - - app_details["app_info"] = { - "id": app_data.get("id"), - "name": app_data.get("name"), - "slug": app_data.get("slug"), - "description": app_data.get("description"), - "owner": app_data.get("owner", {}), - "external_url": app_data.get("external_url"), - "html_url": app_data.get("html_url"), - "created_at": app_data.get("created_at"), - "updated_at": app_data.get("updated_at"), - "permissions": app_data.get("permissions", {}), - "events": app_data.get("events", []), - } - - # 2. Get installations and process repository data - app_details["installations"] = list_installations(jwt_token) - - # 3. Aggregate data from all installations - repo_aggregation = { - "total_repos": 0, - "all_repos": [], - "permissions_used": set(), - "events_used": set(), - } - - for installation in app_details["installations"]: - repos, count, perms, events = _fetch_installation_repos( - jwt_token, installation - ) - repo_aggregation["all_repos"].extend(repos) - repo_aggregation["total_repos"] += count - repo_aggregation["permissions_used"].update(perms) - repo_aggregation["events_used"].update(events) - - # 4. Update app_details with aggregated data - app_details["total_repositories"] = repo_aggregation["total_repos"] - app_details["repository_sample"] = repo_aggregation["all_repos"] - app_details["permissions_summary"] = dict(app_data.get("permissions", {})) - app_details["events_summary"] = list(repo_aggregation["events_used"]) - - elif app_response.status_code == 401: - app_details["error"] = "Invalid JWT token" - elif app_response.status_code == 403: - app_details["error"] = "Insufficient permissions" - else: - app_details["error"] = f"HTTP {app_response.status_code}" - - except requests.exceptions.RequestException as e: - app_details["error"] = f"Request failed: {str(e)}" - - return app_details - - -def _print_app_info(app_info): - """Helper function to print app information section.""" - print(f"\n{Fore.YELLOW}🤖 App Information{Style.RESET_ALL}") - print( - f" {Style.DIM}ID:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['id']}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Name:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['name']}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Slug:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['slug']}{Style.RESET_ALL}" - ) - - if app_info["description"]: - print( - f" {Style.DIM}Description:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['description']}{Style.RESET_ALL}" - ) - - owner = app_info["owner"] - if owner: - print( - f" {Style.DIM}Owner:{Style.RESET_ALL} " - f"{Fore.CYAN}{owner.get('login', 'Unknown')}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Owner Type:{Style.RESET_ALL} " - f"{Fore.CYAN}{owner.get('type', 'Unknown')}{Style.RESET_ALL}" - ) - - if app_info["html_url"]: - print( - f" {Style.DIM}URL:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['html_url']}{Style.RESET_ALL}" - ) - - print( - f" {Style.DIM}Created:{Style.RESET_ALL} " - f"{Fore.CYAN}{app_info['created_at'][:10]}{Style.RESET_ALL}" - ) - - -def _print_installations(installations, total_repositories): - """Helper function to print installations section.""" - print(f"\n{Fore.YELLOW}📍 Installation Summary{Style.RESET_ALL}") - print( - f" {Style.DIM}Total Installations:{Style.RESET_ALL} " - f"{Fore.CYAN}{len(installations)}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Total Repositories:{Style.RESET_ALL} " - f"{Fore.CYAN}{total_repositories}{Style.RESET_ALL}" - ) - - if installations: - print(f" {Style.DIM}Installed On:{Style.RESET_ALL}") - for installation in installations: - account = installation["account"] - target_type = installation["target_type"] - created = installation["created_at"][:10] - status_icon = "✅" if not installation.get("suspended_at") else "âš ī¸" - - print( - f" {status_icon} {Fore.CYAN}{account['login']}{Style.RESET_ALL} " - f"({target_type}) - {created}" - ) - - -def _print_permissions(permissions): - """Helper function to print permissions section.""" - if permissions: - print(f"\n{Fore.YELLOW}🔐 App Permissions{Style.RESET_ALL}") - for perm, level in permissions.items(): - color = ( - Fore.GREEN - if level == "write" - else Fore.YELLOW if level == "read" else Fore.BLUE - ) - icon = "âœī¸" if level == "write" else "đŸ‘ī¸" if level == "read" else "🔧" - print(f" {icon} {color}{perm}: {level}{Style.RESET_ALL}") - - -def _print_events(events): - """Helper function to print events section.""" - if events: - print(f"\n{Fore.YELLOW}📡 Subscribed Events{Style.RESET_ALL}") - for event in sorted(events): - print(f" 📨 {Fore.CYAN}{event}{Style.RESET_ALL}") - - -def _print_repositories(repos, total_repositories): - """Helper function to print repositories section.""" - if repos: - print( - f"\n{Fore.YELLOW}📚 Accessible Repositories ({total_repositories} total)" - f"{Style.RESET_ALL}" - ) - - # Group repositories by installation for better organization - repos_by_installation = {} - for repo in repos: - installation = repo["installation"] - if installation not in repos_by_installation: - repos_by_installation[installation] = [] - repos_by_installation[installation].append(repo["name"]) - - # Print repositories grouped by installation - for installation, repo_names in repos_by_installation.items(): - print(f" {Style.DIM}{installation}:{Style.RESET_ALL}") - for repo_name in repo_names: - print(f" â€ĸ {Fore.CYAN}{repo_name}{Style.RESET_ALL}") - - # Show remaining count if there are more repos - if total_repositories > len(repos): - remaining = total_repositories - len(repos) - print( - f" {Style.DIM}... and {remaining} more repositories" - f"{Style.RESET_ALL}" - ) - - -def print_app_analysis(app_details): - """ - Print comprehensive GitHub App analysis. - - Args: - app_details (dict): App analysis results - """ - print(f"\n{Fore.CYAN}{Style.BRIGHT}=== GitHub App Analysis ==={Style.RESET_ALL}") - - if not app_details["valid"]: - print(f"{Fore.RED}❌ Unable to analyze app{Style.RESET_ALL}") - if app_details["error"]: - print( - f" {Style.DIM}Error:{Style.RESET_ALL} " - f"{Fore.RED}{app_details['error']}{Style.RESET_ALL}" - ) - return - - app_info = app_details["app_info"] - installations = app_details["installations"] - permissions = app_details["permissions_summary"] - events = app_details["events_summary"] - repos = app_details["repository_sample"] - total_repos = app_details["total_repositories"] - - # Print each section using helper functions - _print_app_info(app_info) - _print_installations(installations, total_repos) - _print_permissions(permissions) - _print_events(events) - _print_repositories(repos, total_repos) - - -def handle_app_analysis(app_id, private_key_path=None, private_key_content=None): - """ - Handle GitHub App analysis command. - - Args: - app_id (str): GitHub App ID - private_key_path (str, optional): Path to private key file - private_key_content (str, optional): Private key content directly - """ - logger.info("Starting GitHub App analysis") - - try: - private_key = get_private_key_content(private_key_path, private_key_content) - jwt_token = generate_jwt(app_id, private_key) - - app_details = get_github_app_details(jwt_token) - print_app_analysis(app_details) - - except Exception as e: - print(f"{Fore.RED}❌ Error analyzing app: {e}{Style.RESET_ALL}") - logger.error("App analysis failed: %s", e) - - -def revoke_with_confirmation(token, force=False): - """ - Revoke token with user confirmation and validation. - - Args: - token (str): The token to revoke. - force (bool): Skip confirmation if True. - - Returns: - bool: True if revocation was successful. - """ - # First validate the token - token_info = validate_token(token) - - if not token_info["valid"]: - print(f"{Fore.RED}❌ Token is already invalid or expired{Style.RESET_ALL}") - if "reason" in token_info: - reason = token_info["reason"] - print( - f" {Style.DIM}Reason:{Style.RESET_ALL}" - f" {Fore.RED}{reason}{Style.RESET_ALL}" - ) - return False - - print(f"\n{Fore.CYAN}{Style.BRIGHT}=== Token Information ==={Style.RESET_ALL}") - token_type = token_info.get("type", "Unknown") - repo_count = token_info.get("repositories_count", "N/A") - print( - f" {Style.DIM}Type:{Style.RESET_ALL} {Fore.CYAN}{token_type}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Repositories:{Style.RESET_ALL}" - f" {Fore.CYAN}{repo_count}{Style.RESET_ALL}" - ) - - scopes = token_info.get("scopes", []) - scopes_text = ( - ", ".join(scopes) if scopes and scopes != [""] else "GitHub App permissions" - ) - print( - f" {Style.DIM}Scopes:{Style.RESET_ALL}" - f" {Fore.CYAN}{scopes_text}{Style.RESET_ALL}" - ) - - rate_limit = token_info["rate_limit"] - rate_limit_text = f"{rate_limit['remaining']}/{rate_limit['limit']}" - print( - f" {Style.DIM}Rate limit:{Style.RESET_ALL}" - f" {Fore.CYAN}{rate_limit_text}{Style.RESET_ALL}" - ) - - if not force: - prompt = ( - f"\n{Fore.YELLOW}âš ī¸ Are you sure you want to revoke this token? (y/N): " - f"{Style.RESET_ALL}" - ) - confirmation = input(prompt) - if confirmation.lower() not in ["y", "yes"]: - print(f"{Fore.RED}đŸšĢ Token revocation cancelled{Style.RESET_ALL}") - return False - - try: - success = revoke_installation_token(token) - if success: - print(f"{Fore.GREEN}đŸ—‘ī¸ ✅ Token revoked successfully{Style.RESET_ALL}") - return True - print(f"{Fore.RED}❌ Failed to revoke token{Style.RESET_ALL}") - return False - except Exception as error: - print(f"{Fore.RED}❌ Error revoking token: {error}{Style.RESET_ALL}") - return False - - -def print_installation_table(installations): - """ - Print installations in a formatted table. - - Args: - installations (list): List of installation dictionaries. - """ - print( - f"\n{Fore.CYAN}{Style.BRIGHT}=== Available" - f" GitHub App Installations ==={Style.RESET_ALL}" - ) - - if not installations: - print(f"{Fore.YELLOW}âš ī¸ No installations found{Style.RESET_ALL}") - return - - # Table header - header = ( - f"{Style.BRIGHT}{'Installation ID':<20} | {'Account':<20} | " - f"{'Target Type':<15}{Style.RESET_ALL}" - ) - print(header) - print(f"{Style.DIM}{'-' * 60}{Style.RESET_ALL}") - - # Table rows - for installation in installations: - installation_id = installation["id"] - account_name = installation["account"]["login"] - target_type = installation["target_type"] - - row = ( - f"{Fore.CYAN}{installation_id:<20}{Style.RESET_ALL} | " - f"{Fore.CYAN}{account_name:<20}{Style.RESET_ALL} | " - f"{Fore.CYAN}{target_type:<15}{Style.RESET_ALL}" - ) - print(row) - - # Footer info - count_msg = f"{len(installations)} installation(s)" - print(f"\n{Fore.BLUE}â„šī¸ Found {count_msg}{Style.RESET_ALL}") - print( - f"{Fore.BLUE}💡 Use --org or" - f" --installation-id to get a token{Style.RESET_ALL}" - ) - - -def handle_token_validation(token): - """ - Handle token validation command. - - Args: - token (str): The token to validate. - """ - logger.info("Validating token") - token_info = validate_token(token) - - if token_info["valid"]: - print(f"{Fore.GREEN}✅ Token is valid{Style.RESET_ALL}") - token_type = token_info.get("type", "Unknown") - repo_count = token_info.get("repositories_count", "N/A") - - print( - f" {Style.DIM}Type:{Style.RESET_ALL}" - f" {Fore.CYAN}{token_type}{Style.RESET_ALL}" - ) - print( - f" {Style.DIM}Repositories:{Style.RESET_ALL}" - f" {Fore.CYAN}{repo_count}{Style.RESET_ALL}" - ) - - scopes = token_info.get("scopes", []) - scopes_text = ( - ", ".join(scopes) if scopes and scopes != [""] else "GitHub App permissions" - ) - print( - f" {Style.DIM}Scopes:{Style.RESET_ALL}" - f" {Fore.CYAN}{scopes_text}{Style.RESET_ALL}" - ) - - rate_limit = token_info["rate_limit"] - rate_limit_text = f"{rate_limit['remaining']}/{rate_limit['limit']}" - print( - f" {Style.DIM}Rate limit:{Style.RESET_ALL}" - f" {Fore.CYAN}{rate_limit_text}{Style.RESET_ALL}" - ) - return - - print(f"{Fore.RED}❌ Token is invalid or expired{Style.RESET_ALL}") - if "reason" in token_info: - reason = token_info["reason"] - print( - f" {Style.DIM}Reason:{Style.RESET_ALL} {Fore.RED}{reason}{Style.RESET_ALL}" - ) - elif "error" in token_info: - error_msg = token_info["error"] - print( - f" {Style.DIM}Error:{Style.RESET_ALL} " - f" {Fore.RED}{error_msg}{Style.RESET_ALL}" - ) - - -def handle_organization_token(org, installations, jwt_token): - """ - Handle organization token generation. - - Args: - org (str): Organization name. - installations (list): List of installations. - jwt_token (str): JWT token for GitHub App. - - Returns: - bool: True if successful, False if organization not found. - """ - logger.info("Looking for installation for organization: %s", org) - org_installation = next( - (inst for inst in installations if inst["account"]["login"] == org), - None, - ) - - if org_installation: - installation_id = org_installation["id"] - logger.info( - "Found installation ID %s for organization: %s", - installation_id, - org, - ) - installation_token = get_installation_access_token(jwt_token, installation_id) - print(installation_token) - success_msg = f"Token generated for organization '{org}'" - print(f"{Fore.GREEN}🔑 ✅ {success_msg}{Style.RESET_ALL}", file=sys.stderr) - return True - - logger.error("App is not installed in organization: %s", org) - error_msg = f"App is not installed in organization: {org}" - print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) - info_msg = "Run with --list-installations to see available organizations" - print(f"{Fore.BLUE}â„šī¸ {info_msg}{Style.RESET_ALL}", file=sys.stderr) - return False - - -def setup_argument_parser(): - """ - Set up and return the argument parser. - - Returns: - argparse.ArgumentParser: Configured argument parser. - """ - parser = argparse.ArgumentParser( - description="Generate and manage GitHub App installation tokens", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Generate tokens (using private key file) - %(prog)s --org myorg --app-id $APP_ID --private-key-path /path/to/key.pem - - # Generate tokens (using private key content) - %(prog)s --org myorg --app-id $APP_ID --private-key "Content of the private Key" - - %(prog)s --installation-id 98765 --app-id $APP_ID --private-key-path /path/to/key.pem - - # List installations - %(prog)s --list-installations --app-id $APP_ID --private-key-path /path/to/key.pem - - # Analyze GitHub App - %(prog)s --analyze-app --app-id $APP_ID --private-key "Content of the private Key" - - # Token management - %(prog)s --validate-token ghs_TOKEN - %(prog)s --revoke-token ghs_TOKEN - %(prog)s --revoke-token ghs_TOKEN --force - -Environment Variables: - APP_ID GitHub App ID - PRIVATE_KEY_PATH Path to private key file - PRIVATE_KEY Private key content directly - """, - ) - - # Existing arguments - parser.add_argument( - "--app-id", - help="GitHub App ID (or set APP_ID env var)", - metavar="ID", - ) - - # Create mutually exclusive group for private key options - key_group = parser.add_mutually_exclusive_group() - key_group.add_argument( - "--private-key-path", - help="Path to private key file (or set PRIVATE_KEY_PATH env var)", - metavar="PATH", - ) - key_group.add_argument( - "--private-key", - help="Private key content directly (or set PRIVATE_KEY env var)", - metavar="KEY_CONTENT", - ) - - parser.add_argument( - "--org", - help="Organization name to get token for", - metavar="ORG", - ) - parser.add_argument( - "--installation-id", - help="Installation ID (if known)", - metavar="ID", - ) - parser.add_argument( - "--list-installations", - action="store_true", - help="List all installations", - ) - - # App analysis argument - parser.add_argument( - "--analyze-app", - action="store_true", - help="Analyze GitHub App details, permissions, and repositories", - ) - - # Token management arguments - parser.add_argument( - "--validate-token", - help="Validate an existing token", - metavar="TOKEN", - ) - parser.add_argument( - "--revoke-token", - help="Revoke an existing token", - metavar="TOKEN", - ) - parser.add_argument( - "--force", - action="store_true", - help="Skip confirmation prompts", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Enable debug logging", - ) - - return parser - - -def validate_credentials(app_id, private_key_path=None, private_key_content=None): - """Validate required credentials are provided.""" - if not app_id: - logger.error("GitHub App ID is required") - error_msg = ( - "GitHub App ID required. Use --app-id or set APP_ID environment variable" - ) - print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) - sys.exit(1) - - if not private_key_path and not private_key_content: - logger.error("Private key is required") - error_msg = ( - "Private key required. Use --private-key-path, --private-key, or set " - "PRIVATE_KEY_PATH/PRIVATE_KEY environment variable" - ) - print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) - sys.exit(1) +def main(): + """Main entry point.""" + print_banner() + parser = create_parser() + args = parser.parse_args() -def handle_token_operations(args): - """Handle token validation and revocation operations.""" - # Handle token validation (no app-id/private-key required) - if args.validate_token: - handle_token_validation(args.validate_token) - return True + # Set debug logging if requested + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + logger.debug("Debug logging enabled") - # Handle token revocation (no app-id/private-key required) - if args.revoke_token: - logger.info("Revoking token") - success = revoke_with_confirmation(args.revoke_token, args.force) - sys.exit(0 if success else 1) + # Get configuration + config = get_config_from_env(args) - return False + # Initialize components + api_client = GitHubAPIClient() + formatter = OutputFormatter() + # Create token manager + token_manager = TokenManager(api_client, formatter) -def handle_main_operations(args, app_id, private_key_path, private_key_content): - """Handle the main token generation operations.""" try: - logger.info("Starting GitHub App token generation") - logger.info("App ID: %s", app_id) - - if private_key_path: - logger.info("Private key path: %s", private_key_path) + # Handle different operations + if args.validate_token: + token_manager.validate_token(args.validate_token) + elif args.revoke_token: + token_manager.revoke_token(args.revoke_token, args.force) + elif args.analyze_app: + token_manager.analyze_app(config) + elif args.list_installations: + token_manager.list_installations(config) + elif args.org: + token_manager.generate_org_token(config, args.org) + elif args.installation_id: + token_manager.generate_installation_token(config, args.installation_id) else: - logger.info("Private key provided directly") - - private_key = get_private_key_content(private_key_path, private_key_content) - jwt_token = generate_jwt(app_id, private_key) - installations = list_installations(jwt_token) - - if args.list_installations: - logger.info("Listing available installations") - print_installation_table(installations) - return - - # Handle specific installation ID - if args.installation_id: - logger.info("Getting token for installation ID: %s", args.installation_id) - installation_token = get_installation_access_token( - jwt_token, args.installation_id - ) - print(installation_token) - return + # Default: show installations + token_manager.list_installations(config) - # Handle organization lookup - if args.org: - success = handle_organization_token(args.org, installations, jwt_token) - sys.exit(0 if success else 1) - - # Default behavior - show all installations - logger.info("No specific organization or installation ID provided") - print_installation_table(installations) - - except FileNotFoundError: - logger.error("Private key file not found: %s", private_key_path) - error_msg = f"Private key file not found: {private_key_path}" - print(f"{Fore.RED}❌ {error_msg}{Style.RESET_ALL}", file=sys.stderr) - sys.exit(1) - except requests.exceptions.RequestException as req_error: - logger.error("API request failed: %s", req_error) - print( - f"{Fore.RED}❌ Error making API request: {req_error}{Style.RESET_ALL}", - file=sys.stderr, - ) - sys.exit(1) - except (ValueError, jwt.InvalidTokenError) as error: - logger.error("Token/validation error: %s", error) - print(f"{Fore.RED}❌ Error: {error}{Style.RESET_ALL}", file=sys.stderr) - sys.exit(1) except KeyboardInterrupt: - print( - f"\n{Fore.YELLOW}âš ī¸ Operation cancelled by user{Style.RESET_ALL}", - file=sys.stderr, - ) + formatter.print_error("Operation cancelled by user") sys.exit(1) except Exception as error: + formatter.print_error(f"Unexpected error: {error}") logger.error("Unexpected error occurred: %s", error) - print( - f"{Fore.RED}❌ Unexpected error: {error}{Style.RESET_ALL}", - file=sys.stderr, - ) - print( - f"{Fore.BLUE}â„šī¸ Please report this issue with the above " - f"error message{Style.RESET_ALL}", - file=sys.stderr, - ) sys.exit(1) -def main(): - """Main function to handle CLI arguments and generate GitHub App tokens.""" - parser = setup_argument_parser() - args = parser.parse_args() - - # Set debug logging if requested - if args.debug: - logging.getLogger().setLevel(logging.DEBUG) - logger.debug("Debug logging enabled") - - # Handle token operations first (don't need app credentials) - if handle_token_operations(args): - return - - # For other operations, app-id and private-key are required - app_id = args.app_id or os.getenv("APP_ID") - private_key_path = args.private_key_path or os.getenv("PRIVATE_KEY_PATH") - private_key_content = args.private_key or os.getenv("PRIVATE_KEY") - - # Handle app analysis - if args.analyze_app: - validate_credentials(app_id, private_key_path, private_key_content) - handle_app_analysis(app_id, private_key_path, private_key_content) - return - - validate_credentials(app_id, private_key_path, private_key_content) - handle_main_operations(args, app_id, private_key_path, private_key_content) - - if __name__ == "__main__": main() diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py new file mode 100644 index 0000000..41cdb0d --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""Token management operations.""" +import logging + +from .auth import GitHubAppAuth +from .config import Config +from .formatters import OutputFormatter +from .github_api import GitHubAPIClient + +logger = logging.getLogger(__name__) + + +class TokenManager: + """Manage GitHub App token operations.""" + + def __init__(self, api_client: GitHubAPIClient, formatter: OutputFormatter): + self.api_client = api_client + self.formatter = formatter + + def validate_token(self, token: str): + """Validate a GitHub token - no app config needed.""" + self.formatter.print_info("Validating token...") + result = self.api_client.validate_token(token) + formatted_result = self.formatter.format_token_validation(result) + print(formatted_result) + + def revoke_token(self, token: str, force: bool = False): + """Revoke a GitHub token - no app config needed.""" + if not self.formatter.confirm_action( + "Are you sure you want to revoke this token?", force + ): + self.formatter.print_info("Token revocation cancelled") + return + + self.formatter.print_info("Revoking token...") + success = self.api_client.revoke_installation_token(token) + + if success: + self.formatter.print_success("Token revoked successfully") + else: + self.formatter.print_warning("Token was already revoked or not found") + + def list_installations(self, config: Config): + """List GitHub App installations - requires app config.""" + if not config.has_required_config: + self.formatter.print_error( + "App ID and private key are required for listing installations" + ) + return + + # Get private key content + private_key = GitHubAppAuth.get_private_key_content( + config.private_key_path, config.private_key_content + ) + + # Create auth instance and generate JWT + auth = GitHubAppAuth(config.app_id, private_key) + jwt_token = auth.generate_jwt() + + # List installations + installations = self.api_client.list_installations(jwt_token) + + # Format and display + formatted_output = self.formatter.format_installations_table(installations) + print(formatted_output) + + def generate_org_token(self, config: Config, org_name: str): + """Generate token for a specific organization - requires app config.""" + if not config.has_required_config: + self.formatter.print_error( + "App ID and private key are required for token generation" + ) + return + + # Get private key content + private_key = GitHubAppAuth.get_private_key_content( + config.private_key_path, config.private_key_content + ) + + # Create auth instance and generate JWT + auth = GitHubAppAuth(config.app_id, private_key) + jwt_token = auth.generate_jwt() + + # Find installation for organization + installations = self.api_client.list_installations(jwt_token) + + installation_id = None + for installation in installations: + if installation.get("account", {}).get("login") == org_name: + installation_id = installation.get("id") + break + + if not installation_id: + self.formatter.print_error( + f"No installation found for organization: {org_name}" + ) + return + + # Generate access token + access_token = self.api_client.get_installation_access_token( + jwt_token, installation_id + ) + + # Output token + self.formatter.print_token(access_token) + self.formatter.print_success(f"Token generated for organization '{org_name}'") + + def generate_installation_token(self, config: Config, installation_id: str): + """Generate token for a specific installation ID - requires app config.""" + if not config.has_required_config: + self.formatter.print_error( + "App ID and private key are required for token generation" + ) + return + + # Get private key content + private_key = GitHubAppAuth.get_private_key_content( + config.private_key_path, config.private_key_content + ) + + # Create auth instance and generate JWT + auth = GitHubAppAuth(config.app_id, private_key) + jwt_token = auth.generate_jwt() + + # Generate access token + try: + access_token = self.api_client.get_installation_access_token( + jwt_token, int(installation_id) + ) + + # Output token + self.formatter.print_token(access_token) + self.formatter.print_success( + f"Token generated for installation ID: {installation_id}" + ) + + except ValueError as error: + self.formatter.print_error( + f"Invalid installation ID: {installation_id} - {error}" + ) + except Exception as error: + self.formatter.print_error(f"Failed to generate token: {error}") + + def analyze_app(self, config: Config): + """Perform comprehensive GitHub App analysis - requires app config.""" + if not config.has_required_config: + self.formatter.print_error( + "App ID and private key are required for app analysis" + ) + return + + self.formatter.print_info("Analyzing GitHub App...") + + # Get private key content + private_key = GitHubAppAuth.get_private_key_content( + config.private_key_path, config.private_key_content + ) + + # Create auth instance and generate JWT + auth = GitHubAppAuth(config.app_id, private_key) + jwt_token = auth.generate_jwt() + + try: + # Get app information + app_info = self.api_client.get_app_info(jwt_token) + + # Get installations + installations = self.api_client.list_installations(jwt_token) + + # Get repositories for each installation + installation_repos = {} + for installation in installations: + install_id = installation.get("id") + if install_id: + try: + repos = self.api_client.get_installation_repositories( + jwt_token, install_id + ) + installation_repos[install_id] = repos + except Exception as error: + logger.warning( + "Could not fetch repos for installation %s: %s", + install_id, + error, + ) + + # Format and display comprehensive analysis + analysis = self.formatter.format_app_analysis( + app_info, installations, installation_repos + ) + print(analysis) + + except Exception as error: + self.formatter.print_error(f"Failed to analyze app: {error}") + # Fallback to basic installation list + self.list_installations(config) From c46cbcb0eaaf62324d0b699d4689872aa4244498 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 13 Jun 2025 16:12:50 -0300 Subject: [PATCH 07/24] IDP-43-modular --- .github/workflows/precommit.yaml | 2 +- cpk_lib_python_github/README.md | 26 +------------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml index eb72aa3..c0bcadd 100644 --- a/.github/workflows/precommit.yaml +++ b/.github/workflows/precommit.yaml @@ -5,7 +5,7 @@ on: pull_request: branches: [main, develop] push: - branches: [main, feat/IDP-43] + branches: [main, feat/IDP-43-modular] permissions: actions: read diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index 39fb7fe..d5200cd 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -37,16 +37,7 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat ### Install from Source ```bash -# Clone the repository -git clone -cd cpk-lib-python-github - -# Create virtual environment -python3 -m venv venv -source venv/bin/activate - -# Install the package -pip install -e . +pip install git+https://github.com/opencpk/cpk-lib-python-github.git@IDP-43-modular ``` ### Verify Installation @@ -662,21 +653,6 @@ The tool automatically creates log files: - **Content**: All operations, errors, and debug information - **Rotation**: Append mode (consider rotating large files) -### Installation Setup - -Make sure you're in the virtual environment: - -```bash -# Activate virtual environment -source venv/bin/activate - -# Install package -pip install -e . - -# Now try running the command again -github-app-token-generator -``` - ## 📄 License This project is licensed under the GPLv3 License. From 8d5af97b6f866da1e20bb718c000f74a5de79eb8 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 13 Jun 2025 16:16:33 -0300 Subject: [PATCH 08/24] IDP-43-modular --- .github/workflows/precommit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml index c0bcadd..a0504c9 100644 --- a/.github/workflows/precommit.yaml +++ b/.github/workflows/precommit.yaml @@ -5,7 +5,7 @@ on: pull_request: branches: [main, develop] push: - branches: [main, feat/IDP-43-modular] + branches: [main, IDP-43-modular] permissions: actions: read From f52107d92bf09422d83430e172701e94afa24ddb Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 16 Jun 2025 15:12:35 -0300 Subject: [PATCH 09/24] IDP-43-modular: add tests --- .github/workflows/tests.yaml | 63 ++ .gitignore | 1 + .gitleaks.toml | 9 + .pre-commit-config.yaml | 1 + .../tests/__init__.py | 0 .../tests/conftest.py | 121 +++ .../tests/test_github_api.py | 941 ++++++++++++++++++ pyproject.toml | 50 +- 8 files changed, 1185 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 .gitleaks.toml create mode 100644 cpk_lib_python_github/github_app_token_generator_package/tests/__init__.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/tests/conftest.py create mode 100644 cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..c41e5a6 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,63 @@ +--- +name: Tests + +on: + push: + branches: [main, develop, IDP-43-modular] + pull_request: + branches: [main, develop] + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: | + pytest cpk_lib_python_github/github_app_token_generator_package/tests/ -v + + - name: Run tests with coverage + run: | + pytest cpk_lib_python_github/github_app_token_generator_package/tests/ \ + --cov=cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator \ + --cov-report=term-missing + + test-installation: + name: Test Package Installation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Test CLI command + run: | + github-app-token-generator --help diff --git a/.gitignore b/.gitignore index 367524d..55104d4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ *.swp .DS_Store .Python +.coverage .eggs/ .idea/ .installed.cfg diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b8bce2e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,9 @@ +title = "Gitleaks Configuration" + +[allowlist] +description = "Allowlist for test files" +paths = [ + '''cpk_lib_python_github/.*tests/.*''', + '''.*conftest\.py''', + '''.*test_.*\.py''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb70602..8c38385 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,6 +72,7 @@ repos: rev: v8.27.2 hooks: - id: gitleaks + args: ['--config=.gitleaks.toml'] # ----------------------------- # # Generates Table of Contents in Markdown files # # ----------------------------- diff --git a/cpk_lib_python_github/github_app_token_generator_package/tests/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cpk_lib_python_github/github_app_token_generator_package/tests/conftest.py b/cpk_lib_python_github/github_app_token_generator_package/tests/conftest.py new file mode 100644 index 0000000..bccafb9 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/tests/conftest.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Pytest configuration and fixtures for github_app_token_generator_package.""" +import pytest # pylint: disable=import-error + +from ..github_app_token_generator.github_api import GitHubAPIClient + + +@pytest.fixture +def github_api_client(): + """Create a GitHubAPIClient instance for testing.""" + return GitHubAPIClient(timeout=30) + + +@pytest.fixture +def sample_jwt_token(): + """Sample JWT token for testing.""" + return ( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + "eyJpc3MiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNjM5NjI2MDAwLCJleHAiOjE2Mzk2Mjk2MDB9." + "sample_jwt_token" + ) + + +@pytest.fixture +def sample_installation_id(): + """Sample installation ID for testing.""" + return 12345678 + + +@pytest.fixture +def sample_installation_token(): + """Sample installation token for testing.""" + return "ghs_1234567890abcdef1234567890abcdef12345678" + + +@pytest.fixture +def sample_access_token_response(): + """Sample access token response from GitHub API.""" + return { + "token": "ghs_1234567890abcdef1234567890abcdef12345678", + "expires_at": "2023-12-31T23:59:59Z", + "permissions": { + "contents": "read", + "metadata": "read", + "pull_requests": "write", + }, + "repository_selection": "selected", + } + + +@pytest.fixture +def sample_expected_token(): + """Expected token from the response.""" + return "ghs_1234567890abcdef1234567890abcdef12345678" + + +@pytest.fixture +def sample_installations(): + """Sample installations list.""" + return [ + { + "id": 12345678, + "account": {"login": "testorg", "type": "Organization"}, + "target_type": "Organization", + "created_at": "2023-01-01T00:00:00Z", + }, + { + "id": 87654321, + "account": {"login": "testuser", "type": "User"}, + "target_type": "User", + "created_at": "2023-02-01T00:00:00Z", + }, + ] + + +@pytest.fixture +def sample_repositories(): + """Sample repositories response.""" + return { + "total_count": 3, + "repositories": [ + { + "id": 123, + "full_name": "testorg/repo1", + "name": "repo1", + "private": False, + }, + { + "id": 124, + "full_name": "testorg/repo2", + "name": "repo2", + "private": True, + }, + { + "id": 125, + "full_name": "testorg/repo3", + "name": "repo3", + "private": False, + }, + ], + } + + +@pytest.fixture +def sample_app_info(): + """Sample GitHub App information.""" + return { + "id": 123456, + "name": "Test GitHub App", + "slug": "test-github-app", + "description": "A test GitHub App for unit testing", + "owner": {"login": "testorg", "type": "Organization"}, + "html_url": "https://github.com/apps/test-github-app", + "created_at": "2023-01-01T00:00:00Z", + "permissions": { + "contents": "read", + "metadata": "read", + "pull_requests": "write", + }, + "events": ["push", "pull_request"], + } diff --git a/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py new file mode 100644 index 0000000..237a5f2 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py @@ -0,0 +1,941 @@ +# -*- coding: utf-8 -*- +"""Unit tests for GitHubAPIClient - All methods.""" +from unittest.mock import patch + +import pytest # pylint: disable=import-error +import requests +import responses # pylint: disable=import-error + +from ..github_app_token_generator.github_api import GitHubAPIClient + + +class TestGetInstallationAccessToken: + """Test cases for get_installation_access_token method.""" + + @responses.activate + def test_get_installation_access_token_success( + self, + github_api_client, + sample_jwt_token, + sample_installation_id, + sample_access_token_response, + sample_expected_token, + ): # pylint: disable=too-many-positional-arguments + """Test successful installation access token retrieval with MOCK response.""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + responses.add( + responses.POST, expected_url, json=sample_access_token_response, status=201 + ) + + result_token = github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + assert result_token == sample_expected_token + assert len(responses.calls) == 1 + + request = responses.calls[0].request + assert request.method == "POST" + assert request.url == expected_url + assert request.headers["Authorization"] == f"Bearer {sample_jwt_token}" + assert request.headers["Accept"] == "application/vnd.github.v3+json" + + @responses.activate + def test_get_installation_access_token_http_401_unauthorized( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test HTTP 401 error (unauthorized/bad credentials).""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + responses.add( + responses.POST, + expected_url, + json={"message": "Bad credentials"}, + status=401, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + assert exc_info.value.response.status_code == 401 + + @responses.activate + def test_get_installation_access_token_http_404_not_found( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test HTTP 404 error (installation not found).""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + responses.add( + responses.POST, expected_url, json={"message": "Not Found"}, status=404 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + assert exc_info.value.response.status_code == 404 + + @responses.activate + def test_get_installation_access_token_missing_token_field( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test response missing 'token' field.""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + invalid_response = { + "expires_at": "2023-12-31T23:59:59Z", + "permissions": {"contents": "read"}, + } + + responses.add(responses.POST, expected_url, json=invalid_response, status=201) + + with pytest.raises(ValueError) as exc_info: + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + assert "Invalid response: token not found in response" in str(exc_info.value) + + @responses.activate + def test_get_installation_access_token_empty_response( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test empty JSON response.""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + responses.add(responses.POST, expected_url, json={}, status=201) + + with pytest.raises(ValueError) as exc_info: + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + assert "Invalid response: token not found in response" in str(exc_info.value) + + @responses.activate + def test_get_installation_access_token_malformed_json( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test malformed JSON response.""" + expected_url = ( + f"https://api.github.com/app/installations/" + f"{sample_installation_id}/access_tokens" + ) + + responses.add( + responses.POST, + expected_url, + body="invalid json response", + status=201, + content_type="application/json", + ) + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + def test_get_installation_access_token_timeout( + self, sample_jwt_token, sample_installation_id + ): + """Test network timeout.""" + # Create client with very short timeout + github_api_client = GitHubAPIClient(timeout=0.001) + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + @responses.activate + def test_get_installation_access_token_connection_error( + self, github_api_client, sample_jwt_token, sample_installation_id + ): + """Test connection error (no mock response registered).""" + # Don't register any mock response to simulate connection error + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.get_installation_access_token( + sample_jwt_token, sample_installation_id + ) + + +class TestListInstallations: + """Test cases for list_installations method.""" + + @responses.activate + def test_list_installations_success( + self, github_api_client, sample_jwt_token, sample_installations + ): + """Test successful installations listing.""" + expected_url = "https://api.github.com/app/installations" + + responses.add( + responses.GET, expected_url, json=sample_installations, status=200 + ) + + installations = github_api_client.list_installations(sample_jwt_token) + + assert installations == sample_installations + assert len(installations) == 2 + assert len(responses.calls) == 1 + + request = responses.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + assert request.headers["Authorization"] == f"Bearer {sample_jwt_token}" + assert request.headers["Accept"] == "application/vnd.github.v3+json" + + @responses.activate + def test_list_installations_empty_list(self, github_api_client, sample_jwt_token): + """Test empty installations list.""" + expected_url = "https://api.github.com/app/installations" + + responses.add(responses.GET, expected_url, json=[], status=200) + + installations = github_api_client.list_installations(sample_jwt_token) + + assert installations == [] + assert len(installations) == 0 + + @responses.activate + def test_list_installations_http_401_error( + self, github_api_client, sample_jwt_token + ): + """Test installations listing with 401 error.""" + expected_url = "https://api.github.com/app/installations" + + responses.add( + responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.list_installations(sample_jwt_token) + + assert exc_info.value.response.status_code == 401 + + @responses.activate + def test_list_installations_http_403_error( + self, github_api_client, sample_jwt_token + ): + """Test installations listing with 403 error.""" + expected_url = "https://api.github.com/app/installations" + + responses.add( + responses.GET, expected_url, json={"message": "Forbidden"}, status=403 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.list_installations(sample_jwt_token) + + assert exc_info.value.response.status_code == 403 + + @responses.activate + def test_list_installations_network_error( + self, github_api_client, sample_jwt_token + ): + """Test installations listing with network error.""" + # Don't add any responses to simulate network error + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.list_installations(sample_jwt_token) + + +class TestValidateToken: + """Test cases for validate_token method.""" + + @responses.activate + def test_validate_token_success( + self, github_api_client, sample_installation_token, sample_repositories + ): + """Test successful token validation.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, + expected_url, + json=sample_repositories, + status=200, + headers={ + "X-OAuth-Scopes": "repo, user", + "X-RateLimit-Remaining": "4999", + "X-RateLimit-Limit": "5000", + "X-RateLimit-Reset": "1639629600", + }, + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is True + assert result["type"] == "GitHub App Installation Token" + assert result["repositories_count"] == 3 + assert result["scopes"] == ["repo", "user"] + assert result["rate_limit"]["remaining"] == "4999" + assert result["rate_limit"]["limit"] == "5000" + assert result["rate_limit"]["reset"] == "1639629600" + + request = responses.calls[0].request + assert request.headers["Authorization"] == f"token {sample_installation_token}" + + @responses.activate + def test_validate_token_no_scopes( + self, github_api_client, sample_installation_token, sample_repositories + ): + """Test token validation with no scopes header.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, + expected_url, + json=sample_repositories, + status=200, + headers={"X-RateLimit-Remaining": "4999", "X-RateLimit-Limit": "5000"}, + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is True + assert result["scopes"] == [] + + @responses.activate + def test_validate_token_401_invalid( + self, github_api_client, sample_installation_token + ): + """Test token validation with 401 (invalid token).""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is False + assert result["status_code"] == 401 + assert result["reason"] == "Invalid or expired token" + + @responses.activate + def test_validate_token_403_insufficient_permissions( + self, github_api_client, sample_installation_token + ): + """Test token validation with 403 (insufficient permissions).""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Forbidden"}, status=403 + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is False + assert result["status_code"] == 403 + assert result["reason"] == "Insufficient permissions" + + @responses.activate + def test_validate_token_404_not_found( + self, github_api_client, sample_installation_token + ): + """Test token validation with 404 (not found).""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Not Found"}, status=404 + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is False + assert result["status_code"] == 404 + assert result["reason"] == "HTTP 404" + + @responses.activate + def test_validate_token_network_error( + self, github_api_client, sample_installation_token + ): + """Test token validation with network error.""" + # Don't add any responses to simulate network error + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is False + assert "error" in result + + +class TestRevokeInstallationToken: + """Test cases for revoke_installation_token method.""" + + @responses.activate + def test_revoke_installation_token_success( + self, github_api_client, sample_installation_token + ): + """Test successful token revocation.""" + expected_url = "https://api.github.com/installation/token" + + responses.add(responses.DELETE, expected_url, status=204) + + result = github_api_client.revoke_installation_token(sample_installation_token) + + assert result is True + assert len(responses.calls) == 1 + + request = responses.calls[0].request + assert request.method == "DELETE" + assert request.url == expected_url + assert request.headers["Authorization"] == f"token {sample_installation_token}" + + @responses.activate + def test_revoke_installation_token_404_not_found( + self, github_api_client, sample_installation_token + ): + """Test token revocation when token not found.""" + expected_url = "https://api.github.com/installation/token" + + responses.add( + responses.DELETE, expected_url, json={"message": "Not Found"}, status=404 + ) + + result = github_api_client.revoke_installation_token(sample_installation_token) + + assert result is False + + @responses.activate + def test_revoke_installation_token_401_error( + self, github_api_client, sample_installation_token + ): + """Test token revocation with 401 error.""" + expected_url = "https://api.github.com/installation/token" + + responses.add( + responses.DELETE, + expected_url, + json={"message": "Bad credentials"}, + status=401, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.revoke_installation_token(sample_installation_token) + + assert exc_info.value.response.status_code == 401 + + @responses.activate + def test_revoke_installation_token_403_error( + self, github_api_client, sample_installation_token + ): + """Test token revocation with 403 error.""" + expected_url = "https://api.github.com/installation/token" + + responses.add( + responses.DELETE, expected_url, json={"message": "Forbidden"}, status=403 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.revoke_installation_token(sample_installation_token) + + assert exc_info.value.response.status_code == 403 + + @responses.activate + def test_revoke_installation_token_network_error( + self, github_api_client, sample_installation_token + ): + """Test token revocation with network error.""" + # Don't add any responses to simulate network error + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.revoke_installation_token(sample_installation_token) + + +class TestGetAppInfo: + """Test cases for get_app_info method.""" + + @responses.activate + def test_get_app_info_success( + self, github_api_client, sample_jwt_token, sample_app_info + ): + """Test successful app info retrieval.""" + expected_url = "https://api.github.com/app" + + responses.add(responses.GET, expected_url, json=sample_app_info, status=200) + + app_info = github_api_client.get_app_info(sample_jwt_token) + + assert app_info == sample_app_info + assert app_info["name"] == "Test GitHub App" + assert app_info["id"] == 123456 + assert len(responses.calls) == 1 + + request = responses.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + assert request.headers["Authorization"] == f"Bearer {sample_jwt_token}" + + @responses.activate + def test_get_app_info_401_error(self, github_api_client, sample_jwt_token): + """Test app info retrieval with 401 error.""" + expected_url = "https://api.github.com/app" + + responses.add( + responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.get_app_info(sample_jwt_token) + + assert exc_info.value.response.status_code == 401 + + @responses.activate + def test_get_app_info_404_error(self, github_api_client, sample_jwt_token): + """Test app info retrieval with 404 error.""" + expected_url = "https://api.github.com/app" + + responses.add( + responses.GET, expected_url, json={"message": "Not Found"}, status=404 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.get_app_info(sample_jwt_token) + + assert exc_info.value.response.status_code == 404 + + @responses.activate + def test_get_app_info_403_error(self, github_api_client, sample_jwt_token): + """Test app info retrieval with 403 error.""" + expected_url = "https://api.github.com/app" + + responses.add( + responses.GET, expected_url, json={"message": "Forbidden"}, status=403 + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + github_api_client.get_app_info(sample_jwt_token) + + assert exc_info.value.response.status_code == 403 + + @responses.activate + def test_get_app_info_network_error(self, github_api_client, sample_jwt_token): + """Test app info retrieval with network error.""" + # Don't add any responses to simulate network error + + with pytest.raises(requests.exceptions.RequestException): + github_api_client.get_app_info(sample_jwt_token) + + +class TestGetInstallationRepositories: + """Test cases for get_installation_repositories method.""" + + @patch.object(GitHubAPIClient, "get_installation_access_token") + @responses.activate + def test_get_installation_repositories_success( + self, + mock_get_token, + github_api_client, + sample_jwt_token, + sample_installation_id, + sample_installation_token, + sample_repositories, + ): # pylint: disable=too-many-positional-arguments + """Test successful installation repositories retrieval.""" + # Mock the access token retrieval + mock_get_token.return_value = sample_installation_token + + expected_url = "https://api.github.com/installation/repositories" + responses.add(responses.GET, expected_url, json=sample_repositories, status=200) + + repos = github_api_client.get_installation_repositories( + sample_jwt_token, sample_installation_id + ) + + assert repos == sample_repositories + assert repos["total_count"] == 3 + assert len(repos["repositories"]) == 3 + mock_get_token.assert_called_once_with(sample_jwt_token, sample_installation_id) + + request = responses.calls[0].request + assert request.headers["Authorization"] == f"token {sample_installation_token}" + + @patch.object(GitHubAPIClient, "get_installation_access_token") + def test_get_installation_repositories_token_error( + self, + mock_get_token, + github_api_client, + sample_jwt_token, + sample_installation_id, + ): + """Test installation repositories retrieval with token error.""" + # Mock the access token retrieval to raise an exception + mock_get_token.side_effect = requests.exceptions.RequestException( + "Network error" + ) + + repos = github_api_client.get_installation_repositories( + sample_jwt_token, sample_installation_id + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + assert "Network error" in repos["error"] + + @patch.object(GitHubAPIClient, "get_installation_access_token") + @responses.activate + def test_get_installation_repositories_api_error( + self, + mock_get_token, + github_api_client, + sample_jwt_token, + sample_installation_id, + sample_installation_token, + ): # pylint: disable=too-many-positional-arguments + """Test installation repositories retrieval with API error.""" + # Mock successful token retrieval + mock_get_token.return_value = sample_installation_token + + expected_url = "https://api.github.com/installation/repositories" + responses.add( + responses.GET, expected_url, json={"message": "Forbidden"}, status=403 + ) + + repos = github_api_client.get_installation_repositories( + sample_jwt_token, sample_installation_id + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + + @patch.object(GitHubAPIClient, "get_installation_access_token") + @responses.activate + def test_get_installation_repositories_empty_response( + self, + mock_get_token, + github_api_client, + sample_jwt_token, + sample_installation_id, + sample_installation_token, + ): # pylint: disable=too-many-positional-arguments + """Test installation repositories with empty response.""" + # Mock successful token retrieval + mock_get_token.return_value = sample_installation_token + + expected_url = "https://api.github.com/installation/repositories" + empty_repos = {"total_count": 0, "repositories": []} + responses.add(responses.GET, expected_url, json=empty_repos, status=200) + + repos = github_api_client.get_installation_repositories( + sample_jwt_token, sample_installation_id + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + + +class TestGetAccessibleRepositoriesViaToken: + """Test cases for get_accessible_repositories_via_token method.""" + + @responses.activate + def test_get_accessible_repositories_via_token_success( + self, github_api_client, sample_installation_token, sample_repositories + ): + """Test successful repositories retrieval via token.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add(responses.GET, expected_url, json=sample_repositories, status=200) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos == sample_repositories + assert repos["total_count"] == 3 + assert len(repos["repositories"]) == 3 + assert len(responses.calls) == 1 + + request = responses.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + assert request.headers["Authorization"] == f"token {sample_installation_token}" + + @responses.activate + def test_get_accessible_repositories_via_token_empty( + self, github_api_client, sample_installation_token + ): + """Test empty repositories list.""" + expected_url = "https://api.github.com/installation/repositories" + + empty_repos = {"total_count": 0, "repositories": []} + responses.add(responses.GET, expected_url, json=empty_repos, status=200) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + + @responses.activate + def test_get_accessible_repositories_via_token_401_error( + self, github_api_client, sample_installation_token + ): + """Test repositories retrieval via token with 401 error.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 + ) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + + @responses.activate + def test_get_accessible_repositories_via_token_403_error( + self, github_api_client, sample_installation_token + ): + """Test repositories retrieval via token with 403 error.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Forbidden"}, status=403 + ) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + + @responses.activate + def test_get_accessible_repositories_via_token_404_error( + self, github_api_client, sample_installation_token + ): + """Test repositories retrieval via token with 404 error.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, expected_url, json={"message": "Not Found"}, status=404 + ) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + + @responses.activate + def test_get_accessible_repositories_via_token_network_error( + self, github_api_client, sample_installation_token + ): + """Test repositories retrieval via token with network error.""" + # Don't add any responses to simulate network error + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 0 + assert repos["repositories"] == [] + assert "error" in repos + + +class TestGitHubAPIClientEdgeCases: + """Test edge cases and error scenarios.""" + + def test_client_initialization_default_timeout(self): + """Test client initialization with default timeout.""" + client = GitHubAPIClient() + assert client.timeout == 30 + assert client.BASE_URL == "https://api.github.com" + + def test_client_initialization_custom_timeout(self): + """Test client initialization with custom timeout.""" + custom_timeout = 120 + client = GitHubAPIClient(timeout=custom_timeout) + assert client.timeout == custom_timeout + + def test_client_initialization_zero_timeout(self): + """Test client initialization with zero timeout.""" + client = GitHubAPIClient(timeout=0) + assert client.timeout == 0 + + def test_client_initialization_negative_timeout(self): + """Test client initialization with negative timeout.""" + client = GitHubAPIClient(timeout=-1) + assert client.timeout == -1 + + @responses.activate + def test_multiple_method_calls_same_client( + self, github_api_client, sample_jwt_token, sample_installations, sample_app_info + ): + """Test multiple method calls using the same client instance.""" + # Mock installations endpoint + responses.add( + responses.GET, + "https://api.github.com/app/installations", + json=sample_installations, + status=200, + ) + + # Mock app info endpoint + responses.add( + responses.GET, + "https://api.github.com/app", + json=sample_app_info, + status=200, + ) + + # Call multiple methods + installations = github_api_client.list_installations(sample_jwt_token) + app_info = github_api_client.get_app_info(sample_jwt_token) + + assert len(installations) == 2 + assert app_info["name"] == "Test GitHub App" + assert len(responses.calls) == 2 + + @responses.activate + def test_request_headers_consistency(self, github_api_client, sample_jwt_token): + """Test that all methods use consistent headers.""" + # Mock multiple endpoints + responses.add( + responses.GET, + "https://api.github.com/app/installations", + json=[], + status=200, + ) + responses.add(responses.GET, "https://api.github.com/app", json={}, status=200) + + # Call methods + github_api_client.list_installations(sample_jwt_token) + github_api_client.get_app_info(sample_jwt_token) + + # Verify headers + for call in responses.calls: + assert call.request.headers["Accept"] == "application/vnd.github.v3+json" + assert call.request.headers["Authorization"] == f"Bearer {sample_jwt_token}" + + @responses.activate + def test_different_installation_ids( + self, github_api_client, sample_jwt_token, sample_access_token_response + ): + """Test with different installation IDs.""" + test_cases = [123, 456789, 999999999] + + for installation_id in test_cases: + expected_url = ( + f"https://api.github.com/app/installations/" + f"{installation_id}/access_tokens" + ) + + responses.add( + responses.POST, + expected_url, + json=sample_access_token_response, + status=201, + ) + + # Test each installation ID + for installation_id in test_cases: + token = github_api_client.get_installation_access_token( + sample_jwt_token, installation_id + ) + assert token == sample_access_token_response["token"] + + @responses.activate + def test_rate_limiting_headers( + self, github_api_client, sample_installation_token, sample_repositories + ): + """Test handling of rate limiting headers.""" + expected_url = "https://api.github.com/installation/repositories" + + responses.add( + responses.GET, + expected_url, + json=sample_repositories, + status=200, + headers={ + "X-RateLimit-Limit": "5000", + "X-RateLimit-Remaining": "100", + "X-RateLimit-Reset": "1640995200", + "X-RateLimit-Used": "4900", + "X-RateLimit-Resource": "core", + }, + ) + + result = github_api_client.validate_token(sample_installation_token) + + assert result["valid"] is True + assert result["rate_limit"]["limit"] == "5000" + assert result["rate_limit"]["remaining"] == "100" + assert result["rate_limit"]["reset"] == "1640995200" + + @responses.activate + def test_large_repository_count(self, github_api_client, sample_installation_token): + """Test handling of large repository counts.""" + large_repo_response = { + "total_count": 1000, + "repositories": [ + { + "id": i, + "full_name": f"testorg/repo{i}", + "name": f"repo{i}", + "private": i % 2 == 0, + } + for i in range(100) # First 100 repos + ], + } + + expected_url = "https://api.github.com/installation/repositories" + responses.add(responses.GET, expected_url, json=large_repo_response, status=200) + + repos = github_api_client.get_accessible_repositories_via_token( + sample_installation_token + ) + + assert repos["total_count"] == 1000 + assert len(repos["repositories"]) == 100 + + def test_str_representation(self): + """Test string representation of client.""" + client = GitHubAPIClient(timeout=45) + str_repr = str(client) + # Basic check that string representation exists + assert "GitHubAPIClient" in str_repr or "timeout" in str_repr + + def test_client_immutability(self): + """Test that BASE_URL cannot be changed accidentally.""" + client = GitHubAPIClient() + original_url = getattr(client, "BASE_URL") + + # This should not change the class attribute + setattr(client, "BASE_URL", "https://evil.api.com") + + # Create new client to verify class attribute unchanged + new_client = GitHubAPIClient() + new_url = getattr(new_client, "BASE_URL") + assert new_url == original_url diff --git a/pyproject.toml b/pyproject.toml index 62f1356..7847518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,54 @@ dep = [ "pylint>=2.17.0", "mypy>=1.0.0", "pre-commit>=3.0.0" - ] +] + +test = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "responses>=0.24.0", + "pytest-mock>=3.10.0" +] +dev = [ + "cpk-lib-python-github[dep,test]" +] + +[tool.pytest.ini_options] +testpaths = [ + "cpk_lib_python_github/github_app_token_generator_package/tests" +] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--verbose", + "--cov=cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator", + "--cov-report=term-missing", + "--cov-report=html" +] +markers = [ + "unit: Unit tests with mocked responses", + "integration: Integration tests with real API calls", + "slow: Slow running tests" +] + +[tool.coverage.run] +source = ["cpk_lib_python_github"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/__pycache__/*" +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError" +] [tool.black] @@ -90,6 +137,7 @@ disable = [ "too-many-locals" ] + [tool.pylint.design] max-statements = 60 # Increase from default 50 to allow longer functions max-module-lines = 1200 From 30d5a15c70d0e2839c2ec70aa34c0ca1731a1da9 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 16 Jun 2025 15:15:57 -0300 Subject: [PATCH 10/24] IDP-43-modular: add tests --- .github/workflows/tests.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c41e5a6..287f230 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -12,18 +12,14 @@ jobs: name: Run Tests runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] - steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.12' - name: Install dependencies run: | @@ -51,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install package run: | From f4b472903e91e6117497ab2c1181945dc1900997 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 11:30:28 -0300 Subject: [PATCH 11/24] IDP-43-modular: code copilot feedbaack --- .../github_app_token_generator/github_api.py | 5 ++++- .../tests/test_github_api.py | 5 ----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py index 1fbbd8e..f93a729 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py @@ -148,7 +148,10 @@ def revoke_installation_token(self, token: str) -> bool: return True except requests.exceptions.HTTPError as http_error: - if response.status_code == 404: + if ( + http_error.response is not None + and http_error.response.status_code == 404 + ): logger.warning("Token not found or already revoked") return False logger.error("HTTP error occurred while revoking token: %s", http_error) diff --git a/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py index 237a5f2..f097111 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py +++ b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py @@ -172,7 +172,6 @@ def test_get_installation_access_token_connection_error( self, github_api_client, sample_jwt_token, sample_installation_id ): """Test connection error (no mock response registered).""" - # Don't register any mock response to simulate connection error with pytest.raises(requests.exceptions.RequestException): github_api_client.get_installation_access_token( @@ -255,7 +254,6 @@ def test_list_installations_network_error( self, github_api_client, sample_jwt_token ): """Test installations listing with network error.""" - # Don't add any responses to simulate network error with pytest.raises(requests.exceptions.RequestException): github_api_client.list_installations(sample_jwt_token) @@ -373,7 +371,6 @@ def test_validate_token_network_error( self, github_api_client, sample_installation_token ): """Test token validation with network error.""" - # Don't add any responses to simulate network error result = github_api_client.validate_token(sample_installation_token) @@ -458,7 +455,6 @@ def test_revoke_installation_token_network_error( self, github_api_client, sample_installation_token ): """Test token revocation with network error.""" - # Don't add any responses to simulate network error with pytest.raises(requests.exceptions.RequestException): github_api_client.revoke_installation_token(sample_installation_token) @@ -753,7 +749,6 @@ def test_get_accessible_repositories_via_token_network_error( self, github_api_client, sample_installation_token ): """Test repositories retrieval via token with network error.""" - # Don't add any responses to simulate network error repos = github_api_client.get_accessible_repositories_via_token( sample_installation_token From 477f978f0a508264cb1170ce480ef80753f70427 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 11:44:29 -0300 Subject: [PATCH 12/24] IDP-43-modular: code copilot feedbaack --- .../github_app_token_generator/token_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py index 41cdb0d..81e15b4 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py @@ -86,7 +86,7 @@ def generate_org_token(self, config: Config, org_name: str): installation_id = None for installation in installations: - if installation.get("account", {}).get("login") == org_name: + if installation.get("account", {}).get("login").lower() == org_name.lower(): installation_id = installation.get("id") break From 3f7857060c28c43867b65107c17e2df973c047fa Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 13:27:00 -0300 Subject: [PATCH 13/24] IDP-43-modular: code copilot feedbaack --- .github/workflows/tests.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 287f230..5f5efa9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,9 +3,9 @@ name: Tests on: push: - branches: [main, develop, IDP-43-modular] - pull_request: branches: [main, develop] + pull_request: + jobs: test: @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.x' - name: Install dependencies run: | @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.x' - name: Install package run: | From f440fc8b73bbf9beda6c0e6bdc44d62296fd46cc Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 13:30:42 -0300 Subject: [PATCH 14/24] IDP-43-modular: code copilot feedbaack --- .github/workflows/precommit.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml index a0504c9..e151b79 100644 --- a/.github/workflows/precommit.yaml +++ b/.github/workflows/precommit.yaml @@ -3,9 +3,8 @@ name: precommit on: pull_request: - branches: [main, develop] push: - branches: [main, IDP-43-modular] + branches: [main] permissions: actions: read From c315d06a4fee01b0f957ce08df9b99dd90b9b78f Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 13:40:51 -0300 Subject: [PATCH 15/24] IDP-43-modular: code copilot feedbaack --- .pre-commit-hooks.yaml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .pre-commit-hooks.yaml diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml deleted file mode 100644 index ac3d3b8..0000000 --- a/.pre-commit-hooks.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -- id: python-pypi-version-check - name: python-pypi-version-check - description: Check if Python package already exists on PYPI. - # entry: hooks/pypi_bumpversion_check-check - entry: python-pypi-version-check - language: python - -- id: find-and-replace-strings - name: find-and-replace-strings - description: Check if Python package already exists on PYPI. - entry: find-and-replace-strings - # entry: hooks/find_and_replace_strings - language: python From dc613861a396e3d4a21b99cd2ff6241eaacbd53b Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 18 Jun 2025 13:42:32 -0300 Subject: [PATCH 16/24] IDP-43-modular: code copilot feedbaack --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 47d91ac..49a4ea0 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,7 @@ This project is licensed under the **GPLv3 License** - see the [LICENSE](LICENSE ## 📞 Support & Community -- 📧 **Email**: opencepk@gmail.com - 🐛 **Issues**: [GitHub Issues](https://github.com/opencpk/cpk-lib-python-github/issues) -- đŸ’Ŧ **Discussions**: [GitHub Discussions](https://github.com/opencpk/cpk-lib-python-github/discussions) -- 📚 **Documentation**: [GitHub Wiki](https://github.com/opencpk/cpk-lib-python-github/wiki) **Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** From 3108db6d17f12ec815c7633e26e268a3ce45c178 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 19 Jun 2025 16:06:18 -0300 Subject: [PATCH 17/24] IDP-43-modular: clean up readme --- cpk_lib_python_github/README.md | 278 +------------------------------- pyproject.toml | 8 - 2 files changed, 3 insertions(+), 283 deletions(-) diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index d5200cd..9b149e3 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -30,14 +30,13 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat ### Prerequisites -- Python 3.9 or higher - A GitHub App with appropriate permissions - GitHub App private key ### Install from Source ```bash -pip install git+https://github.com/opencpk/cpk-lib-python-github.git@IDP-43-modular +pip install git+https://github.com/opencpk/cpk-lib-python-github.git@main ``` ### Verify Installation @@ -48,7 +47,7 @@ github-app-token-generator --help ## đŸŽ¯ Quick Start -### 1. Set up Environment Variables (Recommended) +### 1. Set up Environment Variables (or pass it by param) ```bash export APP_ID=${{YOUR_APP_ID}} @@ -376,7 +375,7 @@ github-app-token-generator --validate-token $TOKEN github-app-token-generator --revoke-token $TOKEN --force ``` -### 4. **App Health Monitoring** +### 4. **App Analysis ** ```bash # Check app installations and permissions @@ -396,242 +395,6 @@ github-app-token-generator --validate-token ghs_TOKEN github-app-token-generator --installation-id ${{YOUR_INST_ID}} --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem ``` -## 🐍 Python SDK Usage - -### Basic Token Generation (Recommended) - -```python -from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient, TokenManager, OutputFormatter -from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config -import os - -# Create config (handles private key automatically) -config = Config( - app_id=os.getenv("GITHUB_APP_ID"), - private_key_path=os.getenv("GITHUB_PRIVATE_KEY_PATH"), # No manual reading needed! - timeout=30 -) - -# Use with token manager (which calls get_private_key_content internally) -api_client = GitHubAPIClient() -formatter = OutputFormatter() -token_manager = TokenManager(api_client, formatter) - -# This handles all the private key reading automatically -token_manager.generate_org_token(config, "your-organization") -``` - -### Alternative: Using Private Key Content - -```python -from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient -from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config -import os - -config = Config( - app_id=os.getenv("GITHUB_APP_ID"), - private_key_content=os.getenv("GITHUB_PRIVATE_KEY"), # Direct content - timeout=30 -) - - -# Generate token for specific organization -for installation in installations: - if installation.get("account", {}).get("login") == "your-organization": - installation_id = installation.get("id") - access_token = api_client.get_installation_access_token(jwt_token, installation_id) - print(f"Access token: {access_token}") - break -``` - -### Token Validation - -```python -from cpk_lib_python_github import GitHubAPIClient - -api_client = GitHubAPIClient() -result = api_client.validate_token("your_token_here") - -if result.get('valid'): - print(f"✅ Token is valid") - print(f"Type: {result.get('type')}") - print(f"Repositories: {result.get('repositories_count')}") -else: - print(f"❌ Token invalid: {result.get('reason')}") -``` - -### Complete Workflow Example - -```python -#!/usr/bin/env python3 -"""Complete GitHub App token workflow using the SDK.""" - -import os -from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter -from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config - -def main(): - # Create configuration from environment - config = Config( - app_id=os.getenv("GITHUB_APP_ID"), - private_key_path=os.getenv("GITHUB_PRIVATE_KEY_PATH"), - debug=True, - timeout=60 - ) - - # Initialize SDK components - api_client = GitHubAPIClient() - formatter = OutputFormatter(use_colors=True) - token_manager = TokenManager(api_client, formatter) - - try: - # List all installations - print("📋 Listing installations...") - token_manager.list_installations(config) - - # Generate token for organization - print("\n🔑 Generating token...") - token_manager.generate_org_token(config, "your-organization") - - # Analyze the GitHub App - print("\n📊 Analyzing app...") - token_manager.analyze_app(config) - - except Exception as error: - formatter.print_error(f"Operation failed: {error}") - -if __name__ == "__main__": - main() -``` - -### CI/CD Integration Example - -```python -#!/usr/bin/env python3 -"""CI/CD pipeline integration using the SDK.""" - -import os -import sys -from cpk_lib_python_github import GitHubAppAuth, GitHubAPIClient -from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config - -def get_github_token_for_pipeline(): - """Generate GitHub token for CI/CD pipeline.""" - - # Create config from CI environment variables - config = Config( - app_id=os.getenv("GITHUB_APP_ID"), - private_key_content=os.getenv("GITHUB_PRIVATE_KEY"), # Base64 decoded in CI - timeout=30 - ) - - if not config.has_required_config: - print("❌ Missing required GitHub App configuration", file=sys.stderr) - sys.exit(1) - - try: - # Use the built-in helper for private key handling - private_key = GitHubAppAuth.get_private_key_content( - config.private_key_path, - config.private_key_content - ) - - # Generate JWT and access token - auth = GitHubAppAuth(config.app_id, private_key) - api_client = GitHubAPIClient() - - jwt_token = auth.generate_jwt() - - # Get installation ID from environment or find by organization - installation_id = os.getenv("GITHUB_INSTALLATION_ID") - if installation_id: - access_token = api_client.get_installation_access_token(jwt_token, int(installation_id)) - else: - # Find installation by organization - org_name = os.getenv("GITHUB_ORGANIZATION") - installations = api_client.list_installations(jwt_token) - - for installation in installations: - if installation.get("account", {}).get("login") == org_name: - installation_id = installation.get("id") - access_token = api_client.get_installation_access_token(jwt_token, installation_id) - break - else: - print(f"❌ No installation found for organization: {org_name}", file=sys.stderr) - sys.exit(1) - - # Output token for pipeline use - print(access_token) - return access_token - - except Exception as e: - print(f"❌ Failed to generate token: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == "__main__": - get_github_token_for_pipeline() -``` - -### Advanced Configuration Example - -```python -from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter -from cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator.config import Config - -# Advanced configuration with all options -config = Config( - app_id="YOUR_APP_ID", - private_key_path="/path/to/private-key.pem", - timeout=60, - debug=True -) - -# Initialize components with custom settings -api_client = GitHubAPIClient(timeout=60) -formatter = OutputFormatter(use_colors=False) # For CI/scripts -token_manager = TokenManager(api_client, formatter) - -# Perform multiple operations -token_manager.list_installations(config) -token_manager.analyze_app(config) -token_manager.generate_org_token(config, "your-organization") -``` - -## 🔧 Troubleshooting - -### Common Issues - -#### 1. **"Private key file not found"** -```bash -# Check file path -ls -la bot.pem - -# Use absolute path -github-app-token-generator --private-key-path /absolute/path/to/bot.pem -``` - -#### 2. **"App is not installed in organization"** -```bash -# List available installations -github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem - -# Use correct organization name from the list -``` - -#### 3. **"Invalid JWT token"** -```bash -# Check app ID and private key -github-app-token-generator --debug --analyze-app --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem -``` - -#### 4. **"Token is invalid or expired"** -```bash -# Generate a new token -github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem - -# GitHub App tokens expire after 1 hour -``` - ### Debug Mode Enable debug logging for detailed troubleshooting: @@ -660,38 +423,3 @@ This project is licensed under the GPLv3 License. ## 🤝 Contributing Contributions are welcome! Please feel free to submit issues and pull requests. - -## 📞 Support - -For support and questions: -- 📧 Email: opencepk@gmail.com -- 🐛 Issues: Submit via GitHub Issues -- 📚 Documentation: This README and built-in help (`--help`) - ---- - -## đŸ—ēī¸ Roadmap - -### Current (v1.x) -- ✅ GitHub App Token Generator -- ✅ CLI interface with rich output -- ✅ Comprehensive token management -- ✅ Python SDK for programmatic access - -### Upcoming (v2.x) -- 🔄 Repository bulk operations -- 🔄 Issue lifecycle automation -- 🔄 Pull request workflow tools -- 🔄 Webhook processing utilities - -### Future (v3.x) -- 🔮 GitHub Actions integration -- 🔮 Advanced analytics and reporting -- 🔮 Multi-organization management -- 🔮 GraphQL API integration - ---- - -**Made with â¤ī¸ by the CPK Cloud Engineering Platform Kit team** - -*Empowering development teams with powerful GitHub automation tools.* diff --git a/pyproject.toml b/pyproject.toml index 7847518..8500be5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,14 +2,6 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" -# This is commented as the next section is a better approach for this use case but may need the following in the future depending the future decisions -# packages = [ -# "cpk_lib_python_github", -# "cpk_lib_python_github.github_app_token_generator_package", -# "cpk_lib_python_github.github_app_token_generator_package.github_app_token_generator" -# ] -# packages = { find = { where = ["."] } } Just FYI in case we need it in the future: this can also be used but it is too permissive - [tool.setuptools.packages.find] where = ["."] include = ["cpk_lib_python_github*"] From 417774691604a54e7d43ed687c33e1daf6779f02 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 19 Jun 2025 18:22:30 -0300 Subject: [PATCH 18/24] IDP-43-modular: cleanup readme --- cpk_lib_python_github/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index 9b149e3..2ad22c8 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -13,7 +13,6 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat - [Sample Outputs](#-sample-outputs) - [Common Use Cases](#-common-use-cases) - [Python SDK Usage](#-python-sdk-usage) -- [Troubleshooting](#-troubleshooting) ## ✨ Features @@ -33,7 +32,7 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat - A GitHub App with appropriate permissions - GitHub App private key -### Install from Source +### Install from Source (later on this will be published to pypi) ```bash pip install git+https://github.com/opencpk/cpk-lib-python-github.git@main @@ -409,6 +408,7 @@ This will show: - 📊 Installation lookup process - âš ī¸ Warning messages and errors + ### Log Files The tool automatically creates log files: From 4aeaa5d2cd6c7073379ab06feb8d7e0e40ff49ed Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 19 Jun 2025 18:59:52 -0300 Subject: [PATCH 19/24] IDP-43-modular: cleanup readme --- cpk_lib_python_github/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index 2ad22c8..80a5d79 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -12,7 +12,6 @@ A powerful CLI tool for generating, managing, and analyzing GitHub App installat - [Environment Variables](#-environment-variables) - [Sample Outputs](#-sample-outputs) - [Common Use Cases](#-common-use-cases) -- [Python SDK Usage](#-python-sdk-usage) ## ✨ Features @@ -107,17 +106,17 @@ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key #### Validate an existing token: ```bash -github-app-token-generator --validate-token ghs_TOKEN +github-app-token-generator --validate-token $ghs_TOKEN ``` #### Revoke a token (with confirmation): ```bash -github-app-token-generator --revoke-token ghs_TOKEN +github-app-token-generator --revoke-token $ghs_TOKEN ``` #### Force revoke a token (no confirmation): ```bash -github-app-token-generator --revoke-token ghs_TOKEN --force +github-app-token-generator --revoke-token $ghs_TOKEN --force ``` ### 🐛 Debug & Help @@ -201,14 +200,14 @@ $ github-app-token-generator --org orginc --app-id ${{YOUR_APP_ID}} --private-ke **Output:** ``` -ghs_TOKEN +$ghs_TOKEN 🔑 ✅ Token generated for organization 'orginc' ``` ### ✅ Token Validation Output ```bash -$ github-app-token-generator --validate-token ghs_TOKEN +$ github-app-token-generator --validate-token $ghs_TOKEN ``` **Output:** @@ -287,7 +286,7 @@ $ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-k ### đŸ—‘ī¸ Token Revocation Output ```bash -$ github-app-token-generator --revoke-token ghs_TOKEN +$ github-app-token-generator --revoke-token $ghs_TOKEN ``` **Output:** @@ -384,7 +383,7 @@ github-app-token-generator --analyze-app --app-id ${{YOUR_APP_ID}} --private-key github-app-token-generator --list-installations --app-id ${{YOUR_APP_ID}} --private-key-path bot.pem # Validate existing tokens -github-app-token-generator --validate-token ghs_TOKEN +github-app-token-generator --validate-token $ghs_TOKEN ``` ### 5. **Quick Token for Specific Installation** From df4d7d80ab648d5c2acf044803f9ba77376e5964 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 19 Jun 2025 19:06:21 -0300 Subject: [PATCH 20/24] IDP-43-modular: update readme --- cpk_lib_python_github/README.md | 27 +++++++++++++++++++ cpk_lib_python_github/__init__.py | 26 +++++++++--------- .../__init__.py | 7 +++-- .../github_app_token_generator/__init__.py | 17 +++--------- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index 80a5d79..cf73243 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -415,6 +415,33 @@ The tool automatically creates log files: - **Content**: All operations, errors, and debug information - **Rotation**: Append mode (consider rotating large files) +## 🐍 Python usage +If you prefer to use this tool as a Python library in your scripts, you can import and use it directly: + +### Quick Token Generation + + + python3 -c "from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter, Config + +# Configure your GitHub App +config = Config( + app_id='YOUR_APP_ID', + private_key_path='YOUR_PATH/bot.pem', + timeout=60, + debug=False +) + +# Initialize components +api_client = GitHubAPIClient(timeout=60) +formatter = OutputFormatter(use_colors=False) +token_manager = TokenManager(api_client, formatter) + +print('🔑 Testing generate_org_token for YOUR_ORG_NAME...') +# Generate token for organization +token_manager.generate_org_token(config, 'YOUR_ORG_NAME') +print('✅ README example completed successfully!') +" + ## 📄 License This project is licensed under the GPLv3 License. diff --git a/cpk_lib_python_github/__init__.py b/cpk_lib_python_github/__init__.py index c0fe8b1..301f7d0 100644 --- a/cpk_lib_python_github/__init__.py +++ b/cpk_lib_python_github/__init__.py @@ -1,21 +1,19 @@ # -*- coding: utf-8 -*- -"""GitHub App token generator package.""" +"""CPK Lib Python GitHub - GitHub App Token Generator Package.""" + from .github_app_token_generator_package.github_app_token_generator import ( - auth, - formatters, - github_api, token_manager, ) +from .github_app_token_generator_package.github_app_token_generator.config import ( + Config, +) +from .github_app_token_generator_package.github_app_token_generator.formatters import ( + OutputFormatter, +) +from .github_app_token_generator_package.github_app_token_generator.github_api import ( + GitHubAPIClient, +) -# Import the classes from modules -GitHubAppAuth = auth.GitHubAppAuth -OutputFormatter = formatters.OutputFormatter -GitHubAPIClient = github_api.GitHubAPIClient TokenManager = token_manager.TokenManager -__all__ = [ - "GitHubAppAuth", - "GitHubAPIClient", - "TokenManager", - "OutputFormatter", -] +__all__ = ["GitHubAPIClient", "TokenManager", "OutputFormatter", "Config"] diff --git a/cpk_lib_python_github/github_app_token_generator_package/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/__init__.py index 5b5640d..2cb0132 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/__init__.py +++ b/cpk_lib_python_github/github_app_token_generator_package/__init__.py @@ -1,3 +1,6 @@ # -*- coding: utf-8 -*- -"""GitHub App token generator package.""" -__all__ = [] +"""GitHub App Token Generator Package.""" + +from .github_app_token_generator import * + +__all__ = ["github_app_token_generator"] diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py index 755ed1f..6ec13e3 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py @@ -1,18 +1,9 @@ # -*- coding: utf-8 -*- -"""GitHub App token generator module.""" -from .auth import GitHubAppAuth -from .config import Config, get_config_from_env +"""GitHub App Token Generator - Core Module.""" + +from .config import Config from .formatters import OutputFormatter from .github_api import GitHubAPIClient -from .main import main from .token_manager import TokenManager -__all__ = [ - "main", - "GitHubAppAuth", - "GitHubAPIClient", - "TokenManager", - "OutputFormatter", - "Config", - "get_config_from_env", -] +__all__ = ["GitHubAPIClient", "TokenManager", "OutputFormatter", "Config"] From f9df219601ee90d237e10261b1bc38c3853ed65a Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 19 Jun 2025 19:10:19 -0300 Subject: [PATCH 21/24] IDP-43-modular: fix formatting --- cpk_lib_python_github/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cpk_lib_python_github/README.md b/cpk_lib_python_github/README.md index cf73243..2ff27c9 100644 --- a/cpk_lib_python_github/README.md +++ b/cpk_lib_python_github/README.md @@ -420,7 +420,7 @@ If you prefer to use this tool as a Python library in your scripts, you can impo ### Quick Token Generation - +```bash python3 -c "from cpk_lib_python_github import GitHubAPIClient, TokenManager, OutputFormatter, Config # Configure your GitHub App @@ -441,6 +441,7 @@ print('🔑 Testing generate_org_token for YOUR_ORG_NAME...') token_manager.generate_org_token(config, 'YOUR_ORG_NAME') print('✅ README example completed successfully!') " +``` ## 📄 License From 56bf4fc5da9010dc890f46d272c1b7dd6956d6c2 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 20 Jun 2025 12:40:14 -0300 Subject: [PATCH 22/24] IDP-43-modular: feedback review --- .../github_app_token_generator/auth.py | 18 +- .../github_app_token_generator/cli.py | 55 +++--- .../github_app_token_generator/config.py | 27 ++- .../github_app_token_generator/formatters.py | 28 +--- .../github_app_token_generator/github_api.py | 45 ++--- .../github_app_token_generator/main.py | 109 +++++++----- .../token_manager.py | 36 +--- .../tests/test_github_api.py | 157 +++++------------- pyproject.toml | 4 +- 9 files changed, 187 insertions(+), 292 deletions(-) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py index ee70026..5702dc4 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py @@ -2,6 +2,7 @@ """GitHub App authentication utilities.""" import logging import time +from typing import Optional import jwt @@ -11,8 +12,8 @@ class GitHubAppAuth: """Handle GitHub App authentication.""" - def __init__(self, app_id: str, private_key: str): - self.app_id = app_id + def __init__(self, app_id: int, private_key: str): + self.app_id = str(app_id) self.private_key = private_key def generate_jwt(self) -> str: @@ -46,7 +47,10 @@ def read_private_key(private_key_path: str) -> str: raise error @staticmethod - def get_private_key_content(private_key_path=None, private_key_content=None) -> str: + def get_private_key_content( + private_key_path: Optional[str] = None, + private_key_content: Optional[str] = None, + ) -> str: """Get private key content from file or direct input.""" if private_key_path and private_key_content: raise ValueError("Cannot specify both --private-key-path and --private-key") @@ -58,8 +62,6 @@ def get_private_key_content(private_key_path=None, private_key_content=None) -> logger.debug("Using private key content provided directly") return private_key_content - if private_key_path: - logger.debug("Reading private key from file: %s", private_key_path) - return GitHubAppAuth.read_private_key(private_key_path) - - raise ValueError("No private key source provided") + # If we reach here, private_key_path must be truthy + logger.debug("Reading private key from file: %s", private_key_path) + return GitHubAppAuth.read_private_key(private_key_path) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py index e8916fe..703c64f 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py @@ -16,14 +16,19 @@ def create_parser() -> argparse.ArgumentParser: epilog=f""" {Fore.GREEN}{Style.BRIGHT}Examples:{Style.RESET_ALL} {Fore.CYAN}# Generate tokens (using private key file){Style.RESET_ALL} - {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--org{Style.RESET_ALL} myorg \\ - {Fore.CYAN}--app-id{Style.RESET_ALL} APP_ID \\ - {Fore.CYAN}--private-key-path{Style.RESET_ALL} /path/to/key.pem + {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--app-id{Style.RESET_ALL} $APP_ID \\ + {Fore.CYAN}--private-key-path{Style.RESET_ALL} /path/to/key.pem \\ + {Fore.CYAN}--org{Style.RESET_ALL} myorg {Fore.CYAN}# Generate tokens (using private key content){Style.RESET_ALL} - {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--org{Style.RESET_ALL} myorg \\ - {Fore.CYAN}--app-id{Style.RESET_ALL} APP_ID \\ - {Fore.CYAN}--private-key{Style.RESET_ALL} "$(cat /path/to/key.pem)" + {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--app-id{Style.RESET_ALL} $APP_ID \\ + {Fore.CYAN}--private-key{Style.RESET_ALL} "$(cat /path/to/key.pem)" \\ + {Fore.CYAN}--org{Style.RESET_ALL} myorg + + {Fore.CYAN}# Using environment variables{Style.RESET_ALL} + {Fore.YELLOW}export APP_ID=$APP_ID{Style.RESET_ALL} + {Fore.YELLOW}export PRIVATE_KEY_PATH=/path/to/key.pem{Style.RESET_ALL} + {Fore.YELLOW}%(prog)s{Style.RESET_ALL} {Fore.CYAN}--org{Style.RESET_ALL} myorg {Fore.BLUE}{Style.BRIGHT}Environment Variables:{Style.RESET_ALL} {Fore.MAGENTA}APP_ID{Style.RESET_ALL} GitHub App ID @@ -34,40 +39,30 @@ def create_parser() -> argparse.ArgumentParser: # Add arguments parser.add_argument( - "--app-id", help="GitHub App ID (or set APP_ID env var)", metavar="ID" + "--app-id", type=int, help="(REQUIRED) GitHub App ID (or set APP_ID env var)", metavar="ID" ) # Private key group key_group = parser.add_mutually_exclusive_group() key_group.add_argument( - "--private-key-path", help="Path to private key file", metavar="PATH" + "--private-key-path", + help="(REQUIRED if private-key not present) Path to private key file", + metavar="PATH", ) key_group.add_argument( - "--private-key", help="Private key content directly", metavar="KEY_CONTENT" + "--private-key", + help="(REQUIRED if private-key-path not present) Private key content directly", + metavar="KEY_CONTENT", ) # Operations - parser.add_argument( - "--org", help="Organization name to get token for", metavar="ORG" - ) - parser.add_argument( - "--installation-id", help="Installation ID (if known)", metavar="ID" - ) - parser.add_argument( - "--list-installations", action="store_true", help="List all installations" - ) - parser.add_argument( - "--analyze-app", action="store_true", help="Analyze GitHub App details" - ) - parser.add_argument( - "--validate-token", help="Validate an existing token", metavar="TOKEN" - ) - parser.add_argument( - "--revoke-token", help="Revoke an existing token", metavar="TOKEN" - ) - parser.add_argument( - "--force", action="store_true", help="Skip confirmation prompts" - ) + parser.add_argument("--org", help="Organization name to get token for", metavar="ORG") + parser.add_argument("--installation-id", help="Installation ID (if known)", metavar="ID") + parser.add_argument("--list-installations", action="store_true", help="List all installations") + parser.add_argument("--analyze-app", action="store_true", help="Analyze GitHub App details") + parser.add_argument("--validate-token", help="Validate an existing token", metavar="TOKEN") + parser.add_argument("--revoke-token", help="Revoke an existing token", metavar="TOKEN") + parser.add_argument("--force", action="store_true", help="Skip confirmation prompts") parser.add_argument("--debug", action="store_true", help="Enable debug logging") return parser diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py index b1f7187..8e94fc9 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py @@ -20,17 +20,6 @@ class Config: timeout: int = 30 debug: bool = False - def __post_init__(self): - """Validate configuration after initialization.""" - if self.app_id: - try: - # Ensure app_id is a valid integer - int(self.app_id) - except ValueError as error: - raise ValueError( - f"Invalid app_id: {self.app_id}. Must be a number." - ) from error - @property def has_private_key(self) -> bool: """Check if private key is configured.""" @@ -64,9 +53,15 @@ def get_config_from_env(args) -> Config: # Check if this operation requires app configuration needs_app_config = requires_app_config(args) + # Handle app_id with proper type conversion + if args.app_id: + config.app_id = args.app_id # Already int from parser + elif os.getenv("APP_ID"): + try: + config.app_id = int(os.getenv("APP_ID")) # Convert env var to int + except ValueError as exc: + raise ValueError(f"Invalid APP_ID environment variable: {os.getenv('APP_ID')}") from exc - # App ID - CLI args take precedence over env vars - config.app_id = args.app_id or os.getenv("APP_ID") if needs_app_config and not config.app_id: logger.error( "App ID is required for this operation. " @@ -82,7 +77,7 @@ def get_config_from_env(args) -> Config: config.private_key_content = args.private_key or os.getenv("PRIVATE_KEY") if needs_app_config and not config.has_private_key: - logger.error( + logger.debug( "Private key is required for this operation. " "Set PRIVATE_KEY or PRIVATE_KEY_PATH environment variable " "or use --private-key/--private-key-path" @@ -135,9 +130,7 @@ def validate_environment() -> bool: return False if not (private_key_path or private_key_content): - logger.warning( - "Neither PRIVATE_KEY_PATH nor PRIVATE_KEY environment variable set" - ) + logger.warning("Neither PRIVATE_KEY_PATH nor PRIVATE_KEY environment variable set") return False # Validate app_id is a number diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py index 8528b3e..1bca46e 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py @@ -108,17 +108,13 @@ def format_token_validation(self, validation_result: Dict[str, Any]) -> str: if "repositories_count" in validation_result: count = validation_result["repositories_count"] - lines.append( - f"Accessible repositories: {Fore.CYAN}{count}{Style.RESET_ALL}" - ) + lines.append(f"Accessible repositories: {Fore.CYAN}{count}{Style.RESET_ALL}") if "rate_limit" in validation_result: rate_limit = validation_result["rate_limit"] remaining = rate_limit.get("remaining", "Unknown") limit = rate_limit.get("limit", "Unknown") - lines.append( - f"Rate limit: {Fore.YELLOW}{remaining}/{limit}{Style.RESET_ALL}" - ) + lines.append(f"Rate limit: {Fore.YELLOW}{remaining}/{limit}{Style.RESET_ALL}") if "scopes" in validation_result and validation_result["scopes"]: scopes = ", ".join(validation_result["scopes"]) @@ -148,8 +144,7 @@ def _format_basic_app_info(self, app_info: Dict[str, Any]) -> str: f" Owner: {Fore.MAGENTA}", f"{owner.get('login', 'Unknown')}{Style.RESET_ALL}", f" Owner Type: {owner.get('type', 'Unknown')}", - f" URL: {Fore.BLUE}" - f"{app_info.get('html_url', 'Unknown')}{Style.RESET_ALL}", + f" URL: {Fore.BLUE}" f"{app_info.get('html_url', 'Unknown')}{Style.RESET_ALL}", f" Created: {Fore.BLUE}", f"{app_info.get('created_at', 'Unknown')}{Style.RESET_ALL}", "", # Empty line @@ -177,14 +172,11 @@ def format_app_analysis( lines.extend( [ f"{Fore.BLUE}{Style.BRIGHT}📍 Installation Summary{Style.RESET_ALL}", - f" Total Installations: {Fore.YELLOW}", - f"{len(installations)}{Style.RESET_ALL}", + f" Total Installations: {Fore.YELLOW}{len(installations)}{Style.RESET_ALL}", ] ) - total_repos = sum( - repos.get("total_count", 0) for repos in installation_repos.values() - ) + total_repos = sum(repos.get("total_count", 0) for repos in installation_repos.values()) lines.extend( [ @@ -215,11 +207,7 @@ def format_app_analysis( # Repository details if installation_repos: - lines.append( - self._format_repo_details( - installations, installation_repos, total_repos - ) - ) + lines.append(self._format_repo_details(installations, installation_repos, total_repos)) return "\n".join(lines) @@ -285,9 +273,7 @@ def _format_repo_details( # Add "more repositories" line if needed if repo_count > threshold: - lines.append( - f" ... and {repo_count - threshold} more repositories" - ) + lines.append(f" ... and {repo_count - threshold} more repositories") return "\n".join(lines) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py index f93a729..d91caa3 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py @@ -16,9 +16,7 @@ class GitHubAPIClient: def __init__(self, timeout: int = 30): self.timeout = timeout - def get_installation_access_token( - self, jwt_token: str, installation_id: int - ) -> str: + def get_installation_access_token(self, jwt_token: str, installation_id: int) -> str: """Get installation access token.""" url = f"{self.BASE_URL}/app/installations/{installation_id}/access_tokens" headers = { @@ -27,9 +25,7 @@ def get_installation_access_token( } try: - logger.debug( - "Requesting access token for installation ID: %s", installation_id - ) + logger.debug("Requesting access token for installation ID: %s", installation_id) response = requests.post(url, headers=headers, timeout=self.timeout) response.raise_for_status() @@ -44,14 +40,10 @@ def get_installation_access_token( return token_data["token"] except requests.exceptions.HTTPError as http_error: - logger.error( - "HTTP error occurred while getting access token: %s", http_error - ) + logger.error("HTTP error occurred while getting access token: %s", http_error) raise http_error except requests.exceptions.RequestException as req_error: - logger.error( - "Request error occurred while getting access token: %s", req_error - ) + logger.error("Request error occurred while getting access token: %s", req_error) raise req_error def list_installations(self, jwt_token: str) -> List[Dict[str, Any]]: @@ -72,14 +64,10 @@ def list_installations(self, jwt_token: str) -> List[Dict[str, Any]]: return installations except requests.exceptions.HTTPError as http_error: - logger.error( - "HTTP error occurred while listing installations: %s", http_error - ) + logger.error("HTTP error occurred while listing installations: %s", http_error) raise http_error except requests.exceptions.RequestException as req_error: - logger.error( - "Request error occurred while listing installations: %s", req_error - ) + logger.error("Request error occurred while listing installations: %s", req_error) raise req_error def validate_token(self, token: str) -> Dict[str, Any]: @@ -122,9 +110,7 @@ def validate_token(self, token: str) -> Dict[str, Any]: return { "valid": False, "status_code": response.status_code, - "reason": error_messages.get( - response.status_code, f"HTTP {response.status_code}" - ), + "reason": error_messages.get(response.status_code, f"HTTP {response.status_code}"), } except requests.exceptions.RequestException as req_error: @@ -148,10 +134,7 @@ def revoke_installation_token(self, token: str) -> bool: return True except requests.exceptions.HTTPError as http_error: - if ( - http_error.response is not None - and http_error.response.status_code == 404 - ): + if http_error.response is not None and http_error.response.status_code == 404: logger.warning("Token not found or already revoked") return False logger.error("HTTP error occurred while revoking token: %s", http_error) @@ -181,15 +164,11 @@ def get_app_info(self, jwt_token: str) -> Dict[str, Any]: logger.error("Error fetching app info: %s", error) raise error - def get_installation_repositories( - self, jwt_token: str, installation_id: int - ) -> Dict[str, Any]: + def get_installation_repositories(self, jwt_token: str, installation_id: int) -> Dict[str, Any]: """Get repositories accessible by installation.""" try: # First get an installation access token - installation_token = self.get_installation_access_token( - jwt_token, installation_id - ) + installation_token = self.get_installation_access_token(jwt_token, installation_id) # Use the installation token to get repositories url = f"{self.BASE_URL}/installation/repositories" @@ -209,9 +188,7 @@ def get_installation_repositories( # Return empty result instead of raising return {"total_count": 0, "repositories": [], "error": str(error)} - def get_accessible_repositories_via_token( - self, installation_token: str - ) -> Dict[str, Any]: + def get_accessible_repositories_via_token(self, installation_token: str) -> Dict[str, Any]: """Get repositories using installation token instead of JWT.""" url = f"{self.BASE_URL}/installation/repositories" headers = { diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py index 4c172f3..fc9f68a 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -24,53 +24,88 @@ logger = logging.getLogger(__name__) +def handle_operations(args, config, token_manager): + """Handle different operations based on command line arguments.""" + if args.validate_token: + token_manager.validate_token(args.validate_token) + elif args.revoke_token: + token_manager.revoke_token(args.revoke_token, args.force) + elif args.analyze_app: + token_manager.analyze_app(config) + elif args.list_installations: + token_manager.list_installations(config) + elif args.org: + token_manager.generate_org_token(config, args.org) + elif args.installation_id: + token_manager.generate_installation_token(config, args.installation_id) + else: + # Default: show installations + token_manager.list_installations(config) + + +def handle_error(error, args=None): + """Handle different types of errors with appropriate messages.""" + if isinstance(error, ValueError): + # Configuration/validation errors - clean user-friendly message + print(f"Error: {error}", file=sys.stderr) + return 1 + if isinstance(error, FileNotFoundError): + # File not found errors - specific guidance + print(f"Error: {error}", file=sys.stderr) + print("Please check that the specified file exists and is readable.", file=sys.stderr) + return 1 + if isinstance(error, PermissionError): + # Permission errors - specific guidance + print(f"Error: {error}", file=sys.stderr) + print("Please check file permissions.", file=sys.stderr) + return 1 + if isinstance(error, KeyboardInterrupt): + # User pressed Ctrl+C - clean exit + print("\nOperation cancelled by user", file=sys.stderr) + return 130 + + # Unexpected errors - show minimal info, suggest debug mode + print(f"Unexpected error: {error}", file=sys.stderr) + + # Only show stack trace in debug mode + if args and hasattr(args, "debug") and args.debug: + logger.exception("Full error details:") + else: + print("Run with --debug for more details.", file=sys.stderr) + + return 1 + + def main(): """Main entry point.""" - print_banner() + args = None + try: + print_banner() - parser = create_parser() - args = parser.parse_args() + parser = create_parser() + args = parser.parse_args() - # Set debug logging if requested - if args.debug: - logging.getLogger().setLevel(logging.DEBUG) - logger.debug("Debug logging enabled") + # Set debug logging if requested + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + logger.debug("Debug logging enabled") - # Get configuration - config = get_config_from_env(args) + # Get configuration - this can raise ValueError for user errors + config = get_config_from_env(args) - # Initialize components - api_client = GitHubAPIClient() - formatter = OutputFormatter() + # Initialize components + api_client = GitHubAPIClient() + formatter = OutputFormatter() - # Create token manager - token_manager = TokenManager(api_client, formatter) + # Create token manager + token_manager = TokenManager(api_client, formatter) - try: # Handle different operations - if args.validate_token: - token_manager.validate_token(args.validate_token) - elif args.revoke_token: - token_manager.revoke_token(args.revoke_token, args.force) - elif args.analyze_app: - token_manager.analyze_app(config) - elif args.list_installations: - token_manager.list_installations(config) - elif args.org: - token_manager.generate_org_token(config, args.org) - elif args.installation_id: - token_manager.generate_installation_token(config, args.installation_id) - else: - # Default: show installations - token_manager.list_installations(config) - - except KeyboardInterrupt: - formatter.print_error("Operation cancelled by user") - sys.exit(1) + handle_operations(args, config, token_manager) + except Exception as error: - formatter.print_error(f"Unexpected error: {error}") - logger.error("Unexpected error occurred: %s", error) - sys.exit(1) + exit_code = handle_error(error, args) + sys.exit(exit_code) if __name__ == "__main__": diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py index 81e15b4..8d07074 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py @@ -26,9 +26,7 @@ def validate_token(self, token: str): def revoke_token(self, token: str, force: bool = False): """Revoke a GitHub token - no app config needed.""" - if not self.formatter.confirm_action( - "Are you sure you want to revoke this token?", force - ): + if not self.formatter.confirm_action("Are you sure you want to revoke this token?", force): self.formatter.print_info("Token revocation cancelled") return @@ -67,9 +65,7 @@ def list_installations(self, config: Config): def generate_org_token(self, config: Config, org_name: str): """Generate token for a specific organization - requires app config.""" if not config.has_required_config: - self.formatter.print_error( - "App ID and private key are required for token generation" - ) + self.formatter.print_error("App ID and private key are required for token generation") return # Get private key content @@ -91,15 +87,11 @@ def generate_org_token(self, config: Config, org_name: str): break if not installation_id: - self.formatter.print_error( - f"No installation found for organization: {org_name}" - ) + self.formatter.print_error(f"No installation found for organization: {org_name}") return # Generate access token - access_token = self.api_client.get_installation_access_token( - jwt_token, installation_id - ) + access_token = self.api_client.get_installation_access_token(jwt_token, installation_id) # Output token self.formatter.print_token(access_token) @@ -108,9 +100,7 @@ def generate_org_token(self, config: Config, org_name: str): def generate_installation_token(self, config: Config, installation_id: str): """Generate token for a specific installation ID - requires app config.""" if not config.has_required_config: - self.formatter.print_error( - "App ID and private key are required for token generation" - ) + self.formatter.print_error("App ID and private key are required for token generation") return # Get private key content @@ -130,23 +120,17 @@ def generate_installation_token(self, config: Config, installation_id: str): # Output token self.formatter.print_token(access_token) - self.formatter.print_success( - f"Token generated for installation ID: {installation_id}" - ) + self.formatter.print_success(f"Token generated for installation ID: {installation_id}") except ValueError as error: - self.formatter.print_error( - f"Invalid installation ID: {installation_id} - {error}" - ) + self.formatter.print_error(f"Invalid installation ID: {installation_id} - {error}") except Exception as error: self.formatter.print_error(f"Failed to generate token: {error}") def analyze_app(self, config: Config): """Perform comprehensive GitHub App analysis - requires app config.""" if not config.has_required_config: - self.formatter.print_error( - "App ID and private key are required for app analysis" - ) + self.formatter.print_error("App ID and private key are required for app analysis") return self.formatter.print_info("Analyzing GitHub App...") @@ -173,9 +157,7 @@ def analyze_app(self, config: Config): install_id = installation.get("id") if install_id: try: - repos = self.api_client.get_installation_repositories( - jwt_token, install_id - ) + repos = self.api_client.get_installation_repositories(jwt_token, install_id) installation_repos[install_id] = repos except Exception as error: logger.warning( diff --git a/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py index f097111..7652ac2 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py +++ b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py @@ -23,13 +23,10 @@ def test_get_installation_access_token_success( ): # pylint: disable=too-many-positional-arguments """Test successful installation access token retrieval with MOCK response.""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) - responses.add( - responses.POST, expected_url, json=sample_access_token_response, status=201 - ) + responses.add(responses.POST, expected_url, json=sample_access_token_response, status=201) result_token = github_api_client.get_installation_access_token( sample_jwt_token, sample_installation_id @@ -50,8 +47,7 @@ def test_get_installation_access_token_http_401_unauthorized( ): """Test HTTP 401 error (unauthorized/bad credentials).""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) responses.add( @@ -74,13 +70,10 @@ def test_get_installation_access_token_http_404_not_found( ): """Test HTTP 404 error (installation not found).""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) - responses.add( - responses.POST, expected_url, json={"message": "Not Found"}, status=404 - ) + responses.add(responses.POST, expected_url, json={"message": "Not Found"}, status=404) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.get_installation_access_token( @@ -95,8 +88,7 @@ def test_get_installation_access_token_missing_token_field( ): """Test response missing 'token' field.""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) invalid_response = { @@ -119,8 +111,7 @@ def test_get_installation_access_token_empty_response( ): """Test empty JSON response.""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) responses.add(responses.POST, expected_url, json={}, status=201) @@ -138,8 +129,7 @@ def test_get_installation_access_token_malformed_json( ): """Test malformed JSON response.""" expected_url = ( - f"https://api.github.com/app/installations/" - f"{sample_installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{sample_installation_id}/access_tokens" ) responses.add( @@ -155,9 +145,7 @@ def test_get_installation_access_token_malformed_json( sample_jwt_token, sample_installation_id ) - def test_get_installation_access_token_timeout( - self, sample_jwt_token, sample_installation_id - ): + def test_get_installation_access_token_timeout(self, sample_jwt_token, sample_installation_id): """Test network timeout.""" # Create client with very short timeout github_api_client = GitHubAPIClient(timeout=0.001) @@ -189,9 +177,7 @@ def test_list_installations_success( """Test successful installations listing.""" expected_url = "https://api.github.com/app/installations" - responses.add( - responses.GET, expected_url, json=sample_installations, status=200 - ) + responses.add(responses.GET, expected_url, json=sample_installations, status=200) installations = github_api_client.list_installations(sample_jwt_token) @@ -218,15 +204,11 @@ def test_list_installations_empty_list(self, github_api_client, sample_jwt_token assert len(installations) == 0 @responses.activate - def test_list_installations_http_401_error( - self, github_api_client, sample_jwt_token - ): + def test_list_installations_http_401_error(self, github_api_client, sample_jwt_token): """Test installations listing with 401 error.""" expected_url = "https://api.github.com/app/installations" - responses.add( - responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 - ) + responses.add(responses.GET, expected_url, json={"message": "Bad credentials"}, status=401) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.list_installations(sample_jwt_token) @@ -234,15 +216,11 @@ def test_list_installations_http_401_error( assert exc_info.value.response.status_code == 401 @responses.activate - def test_list_installations_http_403_error( - self, github_api_client, sample_jwt_token - ): + def test_list_installations_http_403_error(self, github_api_client, sample_jwt_token): """Test installations listing with 403 error.""" expected_url = "https://api.github.com/app/installations" - responses.add( - responses.GET, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.GET, expected_url, json={"message": "Forbidden"}, status=403) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.list_installations(sample_jwt_token) @@ -250,9 +228,7 @@ def test_list_installations_http_403_error( assert exc_info.value.response.status_code == 403 @responses.activate - def test_list_installations_network_error( - self, github_api_client, sample_jwt_token - ): + def test_list_installations_network_error(self, github_api_client, sample_jwt_token): """Test installations listing with network error.""" with pytest.raises(requests.exceptions.RequestException): @@ -316,15 +292,11 @@ def test_validate_token_no_scopes( assert result["scopes"] == [] @responses.activate - def test_validate_token_401_invalid( - self, github_api_client, sample_installation_token - ): + def test_validate_token_401_invalid(self, github_api_client, sample_installation_token): """Test token validation with 401 (invalid token).""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 - ) + responses.add(responses.GET, expected_url, json={"message": "Bad credentials"}, status=401) result = github_api_client.validate_token(sample_installation_token) @@ -339,9 +311,7 @@ def test_validate_token_403_insufficient_permissions( """Test token validation with 403 (insufficient permissions).""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.GET, expected_url, json={"message": "Forbidden"}, status=403) result = github_api_client.validate_token(sample_installation_token) @@ -350,15 +320,11 @@ def test_validate_token_403_insufficient_permissions( assert result["reason"] == "Insufficient permissions" @responses.activate - def test_validate_token_404_not_found( - self, github_api_client, sample_installation_token - ): + def test_validate_token_404_not_found(self, github_api_client, sample_installation_token): """Test token validation with 404 (not found).""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Not Found"}, status=404 - ) + responses.add(responses.GET, expected_url, json={"message": "Not Found"}, status=404) result = github_api_client.validate_token(sample_installation_token) @@ -367,9 +333,7 @@ def test_validate_token_404_not_found( assert result["reason"] == "HTTP 404" @responses.activate - def test_validate_token_network_error( - self, github_api_client, sample_installation_token - ): + def test_validate_token_network_error(self, github_api_client, sample_installation_token): """Test token validation with network error.""" result = github_api_client.validate_token(sample_installation_token) @@ -382,9 +346,7 @@ class TestRevokeInstallationToken: """Test cases for revoke_installation_token method.""" @responses.activate - def test_revoke_installation_token_success( - self, github_api_client, sample_installation_token - ): + def test_revoke_installation_token_success(self, github_api_client, sample_installation_token): """Test successful token revocation.""" expected_url = "https://api.github.com/installation/token" @@ -407,9 +369,7 @@ def test_revoke_installation_token_404_not_found( """Test token revocation when token not found.""" expected_url = "https://api.github.com/installation/token" - responses.add( - responses.DELETE, expected_url, json={"message": "Not Found"}, status=404 - ) + responses.add(responses.DELETE, expected_url, json={"message": "Not Found"}, status=404) result = github_api_client.revoke_installation_token(sample_installation_token) @@ -441,9 +401,7 @@ def test_revoke_installation_token_403_error( """Test token revocation with 403 error.""" expected_url = "https://api.github.com/installation/token" - responses.add( - responses.DELETE, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.DELETE, expected_url, json={"message": "Forbidden"}, status=403) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.revoke_installation_token(sample_installation_token) @@ -464,9 +422,7 @@ class TestGetAppInfo: """Test cases for get_app_info method.""" @responses.activate - def test_get_app_info_success( - self, github_api_client, sample_jwt_token, sample_app_info - ): + def test_get_app_info_success(self, github_api_client, sample_jwt_token, sample_app_info): """Test successful app info retrieval.""" expected_url = "https://api.github.com/app" @@ -489,9 +445,7 @@ def test_get_app_info_401_error(self, github_api_client, sample_jwt_token): """Test app info retrieval with 401 error.""" expected_url = "https://api.github.com/app" - responses.add( - responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 - ) + responses.add(responses.GET, expected_url, json={"message": "Bad credentials"}, status=401) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.get_app_info(sample_jwt_token) @@ -503,9 +457,7 @@ def test_get_app_info_404_error(self, github_api_client, sample_jwt_token): """Test app info retrieval with 404 error.""" expected_url = "https://api.github.com/app" - responses.add( - responses.GET, expected_url, json={"message": "Not Found"}, status=404 - ) + responses.add(responses.GET, expected_url, json={"message": "Not Found"}, status=404) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.get_app_info(sample_jwt_token) @@ -517,9 +469,7 @@ def test_get_app_info_403_error(self, github_api_client, sample_jwt_token): """Test app info retrieval with 403 error.""" expected_url = "https://api.github.com/app" - responses.add( - responses.GET, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.GET, expected_url, json={"message": "Forbidden"}, status=403) with pytest.raises(requests.exceptions.HTTPError) as exc_info: github_api_client.get_app_info(sample_jwt_token) @@ -578,9 +528,7 @@ def test_get_installation_repositories_token_error( ): """Test installation repositories retrieval with token error.""" # Mock the access token retrieval to raise an exception - mock_get_token.side_effect = requests.exceptions.RequestException( - "Network error" - ) + mock_get_token.side_effect = requests.exceptions.RequestException("Network error") repos = github_api_client.get_installation_repositories( sample_jwt_token, sample_installation_id @@ -606,9 +554,7 @@ def test_get_installation_repositories_api_error( mock_get_token.return_value = sample_installation_token expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.GET, expected_url, json={"message": "Forbidden"}, status=403) repos = github_api_client.get_installation_repositories( sample_jwt_token, sample_installation_id @@ -656,9 +602,7 @@ def test_get_accessible_repositories_via_token_success( responses.add(responses.GET, expected_url, json=sample_repositories, status=200) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos == sample_repositories assert repos["total_count"] == 3 @@ -680,9 +624,7 @@ def test_get_accessible_repositories_via_token_empty( empty_repos = {"total_count": 0, "repositories": []} responses.add(responses.GET, expected_url, json=empty_repos, status=200) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 0 assert repos["repositories"] == [] @@ -694,13 +636,9 @@ def test_get_accessible_repositories_via_token_401_error( """Test repositories retrieval via token with 401 error.""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Bad credentials"}, status=401 - ) + responses.add(responses.GET, expected_url, json={"message": "Bad credentials"}, status=401) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 0 assert repos["repositories"] == [] @@ -713,13 +651,9 @@ def test_get_accessible_repositories_via_token_403_error( """Test repositories retrieval via token with 403 error.""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Forbidden"}, status=403 - ) + responses.add(responses.GET, expected_url, json={"message": "Forbidden"}, status=403) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 0 assert repos["repositories"] == [] @@ -732,13 +666,9 @@ def test_get_accessible_repositories_via_token_404_error( """Test repositories retrieval via token with 404 error.""" expected_url = "https://api.github.com/installation/repositories" - responses.add( - responses.GET, expected_url, json={"message": "Not Found"}, status=404 - ) + responses.add(responses.GET, expected_url, json={"message": "Not Found"}, status=404) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 0 assert repos["repositories"] == [] @@ -750,9 +680,7 @@ def test_get_accessible_repositories_via_token_network_error( ): """Test repositories retrieval via token with network error.""" - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 0 assert repos["repositories"] == [] @@ -843,8 +771,7 @@ def test_different_installation_ids( for installation_id in test_cases: expected_url = ( - f"https://api.github.com/app/installations/" - f"{installation_id}/access_tokens" + f"https://api.github.com/app/installations/" f"{installation_id}/access_tokens" ) responses.add( @@ -908,9 +835,7 @@ def test_large_repository_count(self, github_api_client, sample_installation_tok expected_url = "https://api.github.com/installation/repositories" responses.add(responses.GET, expected_url, json=large_repo_response, status=200) - repos = github_api_client.get_accessible_repositories_via_token( - sample_installation_token - ) + repos = github_api_client.get_accessible_repositories_via_token(sample_installation_token) assert repos["total_count"] == 1000 assert len(repos["repositories"]) == 100 diff --git a/pyproject.toml b/pyproject.toml index 8500be5..3368b24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,12 +115,12 @@ exclude_lines = [ [tool.black] -line-length = 88 +line-length = 100 target-version = ['py39'] include = '\.pyi?$' [tool.pylint.format] -max-line-length = 88 # Match Black's line length +max-line-length = 100 [tool.pylint.messages_control] disable = [ From 87b0e466394845569f187b0fff4976374208ccbb Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 20 Jun 2025 12:42:42 -0300 Subject: [PATCH 23/24] IDP-43-modular: feedback review --- .../github_app_token_generator/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py index 8e94fc9..f627d56 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -import toml # Moved from line 158 to top level +import toml logger = logging.getLogger(__name__) From 5d50e4e7d2c0579e814682f1ec711979cf29dea7 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Fri, 20 Jun 2025 12:48:23 -0300 Subject: [PATCH 24/24] IDP-43-modular: feedback review --- .../github_app_token_generator/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py index fc9f68a..7d70aa9 100644 --- a/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -88,6 +88,7 @@ def main(): # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) logger.debug("Debug logging enabled") # Get configuration - this can raise ValueError for user errors