diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml new file mode 100644 index 0000000..e151b79 --- /dev/null +++ b/.github/workflows/precommit.yaml @@ -0,0 +1,23 @@ +--- +name: precommit + +on: + pull_request: + push: + branches: [main] + +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/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..5f5efa9 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,59 @@ +--- +name: Tests + +on: + push: + branches: [main, develop] + pull_request: + + +jobs: + test: + name: Run Tests + 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.x' + + - 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.x' + + - 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 new file mode 100644 index 0000000..55104d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Development files +# IDEs +# Logs +# OS +# Python +# Virtual environments +*$py.class +*.egg +*.egg-info/ +*.log +*.py[cod] +*.so +*.swo +*.swp +.DS_Store +.Python +.coverage +.eggs/ +.idea/ +.installed.cfg +.python-version +.vscode/ +ENV/ +MANIFEST +Thumbs.db +__pycache__/ +build/ +deleted +develop-eggs/ +dist/ +downloads/ +eggs/ +env/ +lib/ +lib64/ +parts/ +pip-wheel-metadata/ +sdist/ +share/python-wheels/ +var/ +venv/ +wheels/ 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 new file mode 100644 index 0000000..8c38385 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,111 @@ +--- +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 + files: ^(requirements.*\.txt|\.gitignore)$ + - 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 + args: ['--config=.gitleaks.toml'] + # ----------------------------- + # # 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/.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/README.md b/README.md index 55133c7..49a4ea0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ -# 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 + +- 🐛 **Issues**: [GitHub Issues](https://github.com/opencpk/cpk-lib-python-github/issues) + + +**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 new file mode 100644 index 0000000..2ff27c9 --- /dev/null +++ b/cpk_lib_python_github/README.md @@ -0,0 +1,452 @@ +# 🔑 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) + +## ✨ 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 + +- A GitHub App with appropriate permissions +- GitHub App private key + +### 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 +``` + +### Verify Installation + +```bash +github-app-token-generator --help +``` + +## đŸŽ¯ Quick Start + +### 1. Set up Environment Variables (or pass it by param) + +```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_TOKEN +``` + +#### Revoke a token (with confirmation): +```bash +github-app-token-generator --revoke-token $ghs_TOKEN +``` + +#### Force revoke a token (no confirmation): +```bash +github-app-token-generator --revoke-token $ghs_TOKEN --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_TOKEN +🔑 ✅ Token generated for organization 'orginc' +``` + +### ✅ Token Validation Output + +```bash +$ github-app-token-generator --validate-token $ghs_TOKEN +``` + +**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_TOKEN +``` + +**Output:** +``` +âš ī¸ 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:** +``` +❌ No installation found for organization: nonexistent-org +``` + +### 🐛 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_TOKEN +🔑 ✅ 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 Analysis ** + +```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_TOKEN +``` + +### 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 +``` + +### 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) + +## 🐍 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 + +```bash + 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. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. diff --git a/cpk_lib_python_github/__init__.py b/cpk_lib_python_github/__init__.py new file mode 100644 index 0000000..301f7d0 --- /dev/null +++ b/cpk_lib_python_github/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""CPK Lib Python GitHub - GitHub App Token Generator Package.""" + +from .github_app_token_generator_package.github_app_token_generator import ( + 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, +) + +TokenManager = token_manager.TokenManager + +__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 new file mode 100644 index 0000000..2cb0132 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""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 new file mode 100644 index 0000000..6ec13e3 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""GitHub App Token Generator - Core Module.""" + +from .config import Config +from .formatters import OutputFormatter +from .github_api import GitHubAPIClient +from .token_manager import TokenManager + +__all__ = ["GitHubAPIClient", "TokenManager", "OutputFormatter", "Config"] 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/auth.py b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py new file mode 100644 index 0000000..5702dc4 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/auth.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""GitHub App authentication utilities.""" +import logging +import time +from typing import Optional + +import jwt + +logger = logging.getLogger(__name__) + + +class GitHubAppAuth: + """Handle GitHub App authentication.""" + + 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: + """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: 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") + + 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 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 new file mode 100644 index 0000000..703c64f --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/cli.py @@ -0,0 +1,83 @@ +# -*- 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}--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}--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 + {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", 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="(REQUIRED if private-key not present) Path to private key file", + metavar="PATH", + ) + key_group.add_argument( + "--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("--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..f627d56 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/config.py @@ -0,0 +1,168 @@ +# -*- 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 + +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 + + @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) + # 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 + + 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.debug( + "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..1bca46e --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/formatters.py @@ -0,0 +1,300 @@ +# -*- 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}{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..d91caa3 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/github_api.py @@ -0,0 +1,207 @@ +# -*- 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 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) + 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 new file mode 100644 index 0000000..7d70aa9 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/main.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""GitHub App Token Generator - Main entry point.""" +import logging +import sys + +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) + +# 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 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.""" + args = None + try: + print_banner() + + parser = create_parser() + args = parser.parse_args() + + # 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 + config = get_config_from_env(args) + + # Initialize components + api_client = GitHubAPIClient() + formatter = OutputFormatter() + + # Create token manager + token_manager = TokenManager(api_client, formatter) + + # Handle different operations + handle_operations(args, config, token_manager) + + except Exception as error: + exit_code = handle_error(error, args) + sys.exit(exit_code) + + +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..8d07074 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/github_app_token_generator/token_manager.py @@ -0,0 +1,178 @@ +# -*- 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").lower() == org_name.lower(): + 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) 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..7652ac2 --- /dev/null +++ b/cpk_lib_python_github/github_app_token_generator_package/tests/test_github_api.py @@ -0,0 +1,861 @@ +# -*- 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).""" + + 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.""" + + 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.""" + + 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.""" + + 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.""" + + 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 new file mode 100644 index 0000000..3368b24 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,135 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["cpk_lib_python_github*"] + +[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" +] + +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] +line-length = 100 +target-version = ['py39'] +include = '\.pyi?$' + +[tool.pylint.format] +max-line-length = 100 + +[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()