diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..c494307 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,86 @@ +# GitHub Action Executor - Cursor AI Rules + +## Project Overview +This is a FastAPI web application for triggering GitHub Actions workflows with OAuth authentication and permission checks. + +## Environment Setup +- **Virtual Environment**: The project uses a Python virtual environment located at `./venv/` +- **Activation**: Always activate venv before running commands: `source venv/bin/activate` (Linux/Mac) or `venv\Scripts\activate` (Windows) +- **Python Version**: Python 3.12 +- **Dependencies**: Install with `pip install -r requirements.txt` (after activating venv) + +## Project Structure +- `app.py` - Main FastAPI application entry point +- `backend/routes/` - API route handlers (auth, workflow, api) +- `backend/services/` - Business logic services (GitHub API, OAuth, permissions) +- `frontend/templates/` - Jinja2 HTML templates +- `frontend/static/` - Static files (CSS, images) +- `tests/` - Pytest test suite +- `config.py` - Application configuration + +## Running Commands +- **Start application (development)**: `python app.py` or `uvicorn app:app --host 0.0.0.0 --port 8000 --reload` +- **Start application (background with nohup)**: `./start.sh` (uses nohup, logs to nohup.out) +- **Stop application (nohup)**: `./stop.sh` (stops process started with start.sh) +- **Run as systemd service**: See "Deployment" section below +- **Run tests**: `pytest tests/ -v` (requires venv activation) +- **Run specific test**: `pytest tests/test_app.py::test_function_name -v` +- **Check syntax**: `python -m py_compile ` + +## Testing +- **Test location**: `tests/` directory +- **Test framework**: pytest with pytest-asyncio +- **Fixtures**: Defined in `tests/conftest.py` +- **Always activate venv before running tests**: `source venv/bin/activate && pytest tests/ -v` +- **Test client**: Uses FastAPI TestClient from `fastapi.testclient` + +## Code Style +- **Language**: Python 3.12 with type hints +- **Framework**: FastAPI (async/await) +- **Templates**: Jinja2 for HTML templates +- **Comments**: Use English for code comments and docstrings +- **Error handling**: Use try/except with proper logging + +## Key Dependencies +- FastAPI - Web framework +- httpx - Async HTTP client for GitHub API +- PyJWT - JWT token generation for GitHub App +- python-dotenv - Environment variable management +- pytest - Testing framework + +## Important Notes +- **Session Management**: Uses Starlette SessionMiddleware for OAuth state +- **GitHub App**: Requires GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and private key +- **OAuth**: Requires GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET +- **Environment Variables**: Loaded from `.env` file via python-dotenv +- **Templates**: Jinja2 templates in `frontend/templates/` with custom filters (urlencode) + +## Common Tasks +- **Adding new route**: Add to appropriate file in `backend/routes/` +- **Adding new service**: Add to `backend/services/` +- **Adding test**: Add to `tests/` directory, follow existing patterns +- **Modifying templates**: Edit files in `frontend/templates/` + + +## Deployment +- **Development mode**: Direct execution with `python app.py` or `uvicorn` with `--reload` +- **Production with nohup**: Use `./start.sh` script (logs to `nohup.out`, PID in `app.pid`) +- **Production with systemd**: Use `github-action-executor.service` file + - Service file location: `github-action-executor.service` + - Installation: `sudo cp github-action-executor.service /etc/systemd/system/` + - Commands: `sudo systemctl start/stop/restart/status github-action-executor` + - Enable on boot: `sudo systemctl enable github-action-executor` + - Logs: `sudo journalctl -u github-action-executor -f` + - **Important**: Edit service file to set correct paths (WorkingDirectory, PATH, User, EnvironmentFile) +- **Service configuration**: Service uses venv Python and loads `.env` file automatically +- **Process management**: When running as service, use systemctl commands, not direct process management + +## When Writing Code +- Always check if venv needs to be activated for Python commands +- Use async/await for all I/O operations (GitHub API calls) +- Add proper error handling and logging +- Write tests for new functionality +- Follow existing code patterns and structure +- Use type hints for function parameters and return values +- **When modifying startup logic**: Consider both development (direct) and production (service) modes + diff --git a/.github/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000..150bdc4 --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,247 @@ +# Badge Generator Scripts + +Universal Python module for generating GitHub Actions workflow trigger badges. + +## 📁 File Structure + +``` +.github/scripts/ +├── generate_badges.py # Core module (low-level API) +├── generate_markdown.py # Markdown generator script +├── badge_config_builder.py # Python API for building configs +├── example_config.json # Example configuration +├── example_config.md # Visualization of example config +├── example_builder_usage.py # Example usage of BadgeConfigBuilder +└── README.md # This file +``` + +## 🎯 Quick Start + +### Option 1: Programmatic Configuration (Recommended) + +```python +from badge_config_builder import BadgeConfigBuilder + +# 1. Create builder (creates temp config) +builder = BadgeConfigBuilder() + +# 2. Add components +builder.add_workflow_table( + title="🧪 Run Tests", + workflow_id="test.yml", + rows=[ + {"sanitizer": "Address", "badge_color": "4caf50", "inputs": {"sanitizer": "address"}}, + {"sanitizer": "Memory", "badge_color": "2196f3", "inputs": {"sanitizer": "memory"}} + ], + column_headers=["Sanitizer", "Actions"], + label_key="sanitizer" +) + +builder.add_workflow_pair("🔨 Build", "build.yml") +builder.set_backport(["release/v1.0", "stable"]) + +# 3. Save config +config_path = builder.save() + +# 4. Generate markdown preview +markdown = builder.generate_markdown( + app_domain="https://your-app.com", + repo_owner="owner", + repo_name="repo", + pr_number=123, + pr_branch="feature", + base_branch="main", + output_path="preview.md" +) +``` + +See `example_builder_usage.py` for a complete example. + +### Option 2: Manual JSON Configuration + +Create a JSON config file (see `example_config.json` for reference): + +```json +{ + "workflows": [ + { + "title": "🧪 Run Tests", + "type": "table", + "workflow_id": "test.yml", + "rows": [...] + } + ], + "backport_branches": ["release/v1.0"], + "legend": {...} +} +``` + +Then use `generate_markdown.py`: + +```bash +python3 .github/scripts/badges/generate_markdown.py \ + --config badge_config.json \ + --app-domain "https://your-app.com" \ + --repo-owner "owner" \ + --repo-name "repo" \ + --pr-number 123 \ + --pr-branch "feature" \ + --base-branch "main" \ + --output comment.txt +``` + +## 📚 Architecture + +### Core Module (`generate_badges.py`) + +**Low-Level Functions (Core API):** +- `create_badge()` - Generate a single badge (direct or UI) +- `create_badge_pair()` - Generate a pair of badges (direct + UI) +- `create_table()` - Generate a markdown table with custom row formatter + +**High-Level Helpers (Optional):** +- `create_backport_table()` - Convenience wrapper for backport tables +- `generate_comment()` - Generate complete PR comment + +### Markdown Generator Script (`generate_markdown.py`) + +Wrapper script used in GitHub Actions workflows. Reads JSON config and generates PR comment. + +### Config Builder (`badge_config_builder.py`) + +Python API for programmatically building configurations: +- `BadgeConfigBuilder()` - Create/load config +- `add_workflow_pair()` - Add simple workflow +- `add_workflow_table()` - Add workflow with table +- `set_backport()` - Configure backport +- `set_legend()` - Configure legend +- `save()` - Save config to file +- `generate_markdown()` - Generate markdown preview + +## 📖 Examples + +- **`example_config.json`** - Complete example configuration +- **`example_config.md`** - Visualization of what the example config generates +- **`example_builder_usage.py`** - Example of using BadgeConfigBuilder programmatically + +## 🔧 Configuration Format + +### Simple Badge Pair + +```json +{ + "workflows": [ + { + "title": "🔨 Build", + "type": "pair", + "workflow_id": "build.yml", + "ref": "main", + "inputs": {"build_type": "release"}, + "badge_color": "2196f3", + "icon": "🔨" + } + ] +} +``` + +### Table with Multiple Rows + +```json +{ + "workflows": [ + { + "title": "🧪 Run Tests", + "type": "table", + "workflow_id": "test.yml", + "column_headers": ["Sanitizer", "Actions"], + "label_key": "sanitizer", + "rows": [ + { + "sanitizer": "Address", + "badge_color": "4caf50", + "inputs": {"sanitizer": "address"} + }, + { + "sanitizer": "Memory", + "badge_color": "2196f3", + "inputs": {"sanitizer": "memory"} + } + ] + } + ] +} +``` + +### Legend Configuration + +```json +{ + "legend": { + "enabled": true, + "direct_text": "▶ - immediately runs the workflow with default parameters.", + "ui_text": "⚙️ - opens UI to review and modify parameters before running." + } +} +``` + +## 🚀 Integration + +### Copy to Your Repository + +```bash +mkdir -p .github/scripts +cp generate_badges.py generate_markdown.py .github/scripts/badges/ +``` + +### Use in GitHub Actions + +```yaml +- name: Generate badges + run: | + python3 .github/scripts/badges/generate_markdown.py \ + --config badge_config.json \ + --app-domain "${{ vars.APP_DOMAIN }}" \ + --repo-owner "${{ github.repository_owner }}" \ + --repo-name "${{ github.event.repository.name }}" \ + --pr-number ${{ github.event.pull_request.number }} \ + --pr-branch "${{ github.event.pull_request.head.ref }}" \ + --base-branch "${{ github.event.pull_request.base.ref }}" \ + --output comment.txt +``` + +## 📝 API Reference + +### BadgeGenerator (Core Module) + +```python +from generate_badges import BadgeGenerator + +gen = BadgeGenerator( + app_domain="https://your-app.com", + repo_owner="owner", + repo_name="repo", + pr_number=123 +) + +# Low-level functions +badge = gen.create_badge("Run Tests", "test.yml", link_type="direct") +pair = gen.create_badge_pair("Run Tests", "test.yml") +table = gen.create_table(rows, headers, formatter) +``` + +### BadgeConfigBuilder + +```python +from badge_config_builder import BadgeConfigBuilder + +builder = BadgeConfigBuilder() +builder.add_workflow_pair("Build", "build.yml") +builder.add_workflow_table("Tests", "test.yml", rows=[...]) +builder.set_backport(["release/v1.0"]) +config_path = builder.save() +markdown = builder.generate_markdown(...) +``` + +## 📄 License + +This script is part of the GitHub Action Executor project. diff --git a/.github/scripts/badges/README.md b/.github/scripts/badges/README.md new file mode 100644 index 0000000..de8be64 --- /dev/null +++ b/.github/scripts/badges/README.md @@ -0,0 +1,243 @@ +# Badge Generator Scripts + +Universal Python module for generating GitHub Actions workflow trigger badges. + +## 📁 File Structure + +``` +.github/scripts/badges/ +├── generate_badges.py # Core module (low-level API) - REQUIRED +├── generate_markdown.py # Markdown generator script - REQUIRED +├── README.md # This file +├── configs/ +│ └── badge_config.json # Main badge configuration +├── preview/ +│ └── preview_config.py # Preview JSON config as markdown +└── examples/ + ├── example_config.json # Example configuration + └── example_config.md # Visualization of example config +``` + +## 🎯 Quick Start + +### Option 0: Preview Your Config (Simplest) + +Edit your JSON config in `.github/scripts/badges/configs/badge_config.json` and preview markdown: + +```bash +# Simple: config -> markdown (prints to stdout, uses defaults) +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json + +# Save to file +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json --output preview.md + +# With custom parameters +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json \ + --app-domain https://my-app.com \ + --repo-owner owner \ + --repo-name repo \ + --pr-number 123 +``` + +### Option 1: JSON Configuration (For GitHub Actions) + +Create a JSON config file (see `configs/badge_config.json` for reference) and use `generate_markdown.py` in your workflow. + +### Option 1: JSON Configuration (For GitHub Actions) + +Create a JSON config file (see `examples/example_config.json` for reference): + +```json +{ + "workflows": [ + { + "title": "🧪 Run Tests", + "type": "table", + "workflow_id": "test.yml", + "rows": [...] + } + ], + "backport_branches": ["release/v1.0"], + "legend": {...} +} +``` + +Then use `generate_markdown.py`: + +```bash + python3 .github/scripts/badges/generate_markdown.py \ + --config badge_config.json \ + --app-domain "https://your-app.com" \ + --repo-owner "owner" \ + --repo-name "repo" \ + --pr-number 123 \ + --pr-branch "feature" \ + --base-branch "main" \ + --output comment.txt +``` + +## 📚 Architecture + +### Core Module (`generate_badges.py`) - **CORE, REQUIRED** + +**Low-Level Functions (Core API):** +- `BadgeGenerator` class - Main generator +- `create_badge()` - Generate a single badge (direct or UI) +- `create_badge_pair()` - Generate a pair of badges (direct + UI) +- `create_table()` - Generate a markdown table with custom row formatter + +**High-Level Helpers (Optional):** +- `create_backport_table()` - Convenience wrapper for backport tables +- `generate_comment()` - Generate complete PR comment + +**Purpose:** Foundation for all badge generation. Used by other modules. + +### Markdown Generator Script (`generate_markdown.py`) - **REQUIRED for GitHub Actions** + +Wrapper script used in GitHub Actions workflows: +- Reads JSON config file +- Uses `BadgeGenerator` from `generate_badges.py` +- Generates complete PR comment +- Used in `.github/workflows/pr-badges.yml` + +**Purpose:** Entry point for GitHub Actions workflows. Reads JSON config and generates markdown. + +### Preview Script (`preview_config.py`) - **For previewing configs** + +Simple script to preview your JSON config as markdown: +- Uses sensible defaults (no need to specify all parameters) +- Can customize all parameters via command-line flags +- Perfect for editing JSON and seeing the result + +**Purpose:** Quick preview tool for editing and testing badge configurations. + +**Usage:** +```bash +# Simple: just config path (uses defaults) +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json + +# Save to file +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json --output preview.md + +# With custom parameters +python3 .github/scripts/badges/preview/preview_config.py configs/badge_config.json \ + --app-domain https://my-app.com \ + --repo-owner owner \ + --repo-name repo \ + --pr-number 123 +``` + +## 📖 Examples + +- **`examples/example_config.json`** - Complete example configuration +- **`examples/example_config.md`** - Visualization of what the example config generates + +## 🔧 Configuration Format + +### Simple Badge Pair + +```json +{ + "workflows": [ + { + "title": "🔨 Build", + "type": "pair", + "workflow_id": "build.yml", + "ref": "main", + "inputs": {"build_type": "release"}, + "badge_color": "2196f3", + "icon": "🔨" + } + ] +} +``` + +### Table with Multiple Rows + +```json +{ + "workflows": [ + { + "title": "🧪 Run Tests", + "type": "table", + "workflow_id": "test.yml", + "column_headers": ["Sanitizer", "Actions"], + "label_key": "sanitizer", + "rows": [ + { + "sanitizer": "Address", + "badge_color": "4caf50", + "inputs": {"sanitizer": "address"} + }, + { + "sanitizer": "Memory", + "badge_color": "2196f3", + "inputs": {"sanitizer": "memory"} + } + ] + } + ] +} +``` + +### Legend Configuration + +```json +{ + "legend": { + "enabled": true, + "direct_text": "▶ - immediately runs the workflow with default parameters.", + "ui_text": "⚙️ - opens UI to review and modify parameters before running." + } +} +``` + +## 🚀 Integration + +### Copy to Your Repository + +```bash +mkdir -p .github/scripts/badges +cp generate_badges.py generate_markdown.py .github/scripts/badges/ +``` + +### Use in GitHub Actions + +```yaml +- name: Generate badges + run: | + python3 .github/scripts/badges/generate_markdown.py \ + --config badge_config.json \ + --app-domain "${{ vars.APP_DOMAIN }}" \ + --repo-owner "${{ github.repository_owner }}" \ + --repo-name "${{ github.event.repository.name }}" \ + --pr-number ${{ github.event.pull_request.number }} \ + --pr-branch "${{ github.event.pull_request.head.ref }}" \ + --base-branch "${{ github.event.pull_request.base.ref }}" \ + --output comment.txt +``` + +## 📝 API Reference + +### BadgeGenerator (Core Module) + +```python +from generate_badges import BadgeGenerator + +gen = BadgeGenerator( + app_domain="https://your-app.com", + repo_owner="owner", + repo_name="repo", + pr_number=123 +) + +# Low-level functions +badge = gen.create_badge("Run Tests", "test.yml", link_type="direct") +pair = gen.create_badge_pair("Run Tests", "test.yml") +table = gen.create_table(rows, headers, formatter) +``` + + +## 📄 License + +This script is part of the GitHub Action Executor project. diff --git a/.github/scripts/badges/configs/backport_branches.json b/.github/scripts/badges/configs/backport_branches.json new file mode 100644 index 0000000..d7da45d --- /dev/null +++ b/.github/scripts/badges/configs/backport_branches.json @@ -0,0 +1,6 @@ +[ + "release/v1.0", + "release/v2.0", + "stable" +] + diff --git a/.github/scripts/badges/configs/badge_config.json b/.github/scripts/badges/configs/badge_config.json new file mode 100644 index 0000000..3886960 --- /dev/null +++ b/.github/scripts/badges/configs/badge_config.json @@ -0,0 +1,117 @@ +{ + "blocks": [ + { + "type": "text", + "order": 0, + "enabled": true, + "text": "## 🚀 Quick Actions" + }, + { + "type": "badge", + "badge_type": "table", + "order": 50, + "enabled": true, + "title": "🧪 Run Tests", + "workflow_id": "test.yml", + "ref": "main", + "inputs": { + "pr_branch": "feature-branch", + "test_type": "all", + "from_pr": "123" + }, + "badge_color": "4caf50", + "icon": "▶", + "column_headers": [ + "Test Type", + "Actions" + ], + "label_key": "test_type", + "rows": [ + { + "test_type": "All", + "badge_color": "4caf50", + "inputs": { + "test_type": "all" + } + }, + { + "test_type": "Unit", + "badge_color": "2196f3", + "inputs": { + "test_type": "unit" + } + }, + { + "test_type": "Integration", + "badge_color": "ff9800", + "inputs": { + "test_type": "integration" + } + } + ] + }, + { + "type": "badge", + "badge_type": "table", + "order": 99, + "enabled": true, + "title": "📦 Backport", + "workflow_id": "backport.yml", + "ref": "main", + "badge_color": "2196f3", + "icon": "▶", + "column_headers": [ + "Branch", + "Actions" + ], + "label_key": "branch", + "badge_text": "Backport", + "branches_file": "backport_branches.json" + }, + { + "type": "text", + "order": 99.3, + "enabled": true, + "text": "Choose branches to backport manually:" + }, + { + "type": "badge", + "badge_type": "pair", + "order": 99.5, + "enabled": true, + "title": "📦 Backport (Custom)", + "workflow_id": "backport.yml", + "ref": "main", + "badge_color": "9c27b0", + "icon": "⚙️", + "only_ui": true, + "hide_title": true, + "inputs": { + "pr_branch": "feature-branch", + "from_pr": "123", + "preset_branches": "backport_branches.json" + } + }, + { + "type": "text", + "order": 100, + "enabled": true, + "text": [ + "▶ - immediately runs the workflow with default parameters.", + "", + "⚙️ - opens UI to review and modify parameters before running." + ] + }, + { + "type": "text", + "order": 101, + "enabled": true, + "separator": true, + "text": [ + "*These links will automatically comment on this PR with the workflow results.*", + "", + "*Tip: To open links in a new tab, use Ctrl+Click (Windows/Linux) or Cmd+Click (macOS).*" + ] + } + ] +} diff --git a/.github/scripts/badges/examples/example_config.json b/.github/scripts/badges/examples/example_config.json new file mode 100644 index 0000000..037be87 --- /dev/null +++ b/.github/scripts/badges/examples/example_config.json @@ -0,0 +1,72 @@ +{ + "workflows": [ + { + "title": "🧪 Run Tests", + "type": "table", + "workflow_id": "test.yml", + "ref": "main", + "column_headers": ["Sanitizer", "Actions"], + "label_key": "sanitizer", + "inputs": { + "pr_branch": "feature-branch", + "test_type": "all", + "from_pr": "123" + }, + "badge_color": "4caf50", + "icon": "▶", + "rows": [ + { + "sanitizer": "Address", + "badge_color": "4caf50", + "inputs": { + "sanitizer": "address" + } + }, + { + "sanitizer": "Memory", + "badge_color": "2196f3", + "inputs": { + "sanitizer": "memory" + } + }, + { + "sanitizer": "Undefined Behavior", + "badge_color": "ff9800", + "inputs": { + "sanitizer": "undefined" + } + }, + { + "sanitizer": "Thread", + "badge_color": "9c27b0", + "inputs": { + "sanitizer": "thread" + } + } + ] + }, + { + "title": "🔨 Build", + "type": "pair", + "workflow_id": "build.yml", + "ref": "main", + "inputs": { + "build_type": "release" + }, + "badge_color": "2196f3", + "icon": "🔨" + } + ], + "backport_branches": [ + "release/v1.0", + "release/v2.0", + "stable" + ], + "backport_workflow_id": "backport.yml", + "legend": { + "enabled": true, + "direct_text": "▶ - immediately runs the workflow with default parameters.", + "ui_text": "⚙️ - opens UI to review and modify parameters before running." + } +} + diff --git a/.github/scripts/badges/examples/example_config.md b/.github/scripts/badges/examples/example_config.md new file mode 100644 index 0000000..bcbef2d --- /dev/null +++ b/.github/scripts/badges/examples/example_config.md @@ -0,0 +1,105 @@ +# Example Config Visualization + +This document shows what the `example_config.json` configuration generates. + +## Configuration Overview + +The example config creates a PR comment with: +- **Run Tests** section with a table of 4 sanitizers (each with different colors) +- **Build** section with a simple badge pair +- **Backport** section with a table of branches +- **Legend** explaining the badge icons + +## Generated PR Comment + +### 🧪 Run Tests + +| Sanitizer | Actions | +|-----------|---------| +| **Address** | [![▶ Run Address](https://img.shields.io/badge/▶_Run_Address-4caf50?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | +| **Memory** | [![▶ Run Memory](https://img.shields.io/badge/▶_Run_Memory-2196f3?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | +| **Undefined Behavior** | [![▶ Run Undefined Behavior](https://img.shields.io/badge/▶_Run_Undefined_Behavior-ff9800?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | +| **Thread** | [![▶ Run Thread](https://img.shields.io/badge/▶_Run_Thread-9c27b0?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | + +### 🔨 Build + +[![🔨 Build](https://img.shields.io/badge/🔨_Build-2196f3?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) + +### 📦 Backport + +| Branch | Actions | +|--------|---------| +| `release/v1.0` | [![▶ Backport](https://img.shields.io/badge/▶_Backport-2196f3?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | +| `release/v2.0` | [![▶ Backport](https://img.shields.io/badge/▶_Backport-2196f3?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | +| `stable` | [![▶ Backport](https://img.shields.io/badge/▶_Backport-2196f3?style=flat-square)](...) [![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](...) | + +▶ - immediately runs the workflow with default parameters. +⚙️ - opens UI to review and modify parameters before running. + +--- + +*These links will automatically comment on this PR with the workflow results.* + +*Tip: To open links in a new tab, use Ctrl+Click (Windows/Linux) or Cmd+Click (macOS).* + +## Configuration Breakdown + +### Run Tests Table + +- **Type**: `table` - creates a markdown table +- **Column Headers**: `["Sanitizer", "Actions"]` +- **Label Key**: `sanitizer` - field name in row data for the label +- **Base Inputs**: Applied to all rows + - `pr_branch`: PR branch name + - `test_type`: "all" + - `from_pr`: PR number +- **Rows**: 4 sanitizers with different colors + - Address (green: `4caf50`) + - Memory (blue: `2196f3`) + - Undefined Behavior (orange: `ff9800`) + - Thread (purple: `9c27b0`) + +### Build Pair + +- **Type**: `pair` (default) - creates a simple badge pair +- **Workflow**: `build.yml` +- **Color**: Blue (`2196f3`) +- **Icon**: 🔨 + +### Backport Table + +- **Workflow**: `backport.yml` +- **Branches**: `release/v1.0`, `release/v2.0`, `stable` +- Automatically generates table with badges for each branch + +### Legend + +- **Enabled**: `true` +- **Direct Text**: Explains ▶ badge +- **UI Text**: Explains ⚙️ badge + +## Color Reference + +- `4caf50` - Green (Address sanitizer) +- `2196f3` - Blue (Memory sanitizer, Build) +- `ff9800` - Orange (Undefined Behavior sanitizer, UI badges) +- `9c27b0` - Purple (Thread sanitizer) + +## Testing the Config + +To test this config locally: + +```bash +python3 .github/scripts/badges/generate_markdown.py \ + --config .github/scripts/example_config.json \ + --app-domain "https://your-app.com" \ + --repo-owner "owner" \ + --repo-name "repo" \ + --pr-number 123 \ + --pr-branch "feature-branch" \ + --base-branch "main" \ + --output /tmp/test_comment.md +``` + +Then view `/tmp/test_comment.md` to see the generated comment. + diff --git a/.github/scripts/badges/generate_badges.py b/.github/scripts/badges/generate_badges.py new file mode 100755 index 0000000..23a6834 --- /dev/null +++ b/.github/scripts/badges/generate_badges.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +""" +Universal GitHub Actions Badge Generator + +This module provides utilities for generating workflow trigger badges +that can be used in PR comments, README files, or documentation. + +Usage: + from generate_badges import BadgeGenerator + + generator = BadgeGenerator( + app_domain="https://your-app.com", + repo_owner="owner", + repo_name="repo", + pr_number=123 + ) + + # 1. Generate a single badge (direct or UI) + direct_badge = generator.create_badge( + text="Run Tests", + workflow_id="test.yml", + link_type="direct", + inputs={"test_type": "all"} + ) + + ui_badge = generator.create_badge( + text="Run Tests", + workflow_id="test.yml", + link_type="ui", + inputs={"test_type": "all"} + ) + + # 2. Generate a badge pair (direct + UI together) + badge_pair = generator.create_badge_pair( + text="Run Tests", + workflow_id="test.yml", + inputs={"test_type": "all"} + ) + + # 3. Generate a table with different values + rows = [ + {"branch": "release/v1.0", "workflow_id": "backport.yml", "inputs": {...}}, + {"branch": "stable", "workflow_id": "backport.yml", "inputs": {...}} + ] + + def formatter(row, gen): + branch = row["branch"] + badges = gen.create_badge_pair("Backport", row["workflow_id"], inputs=row["inputs"]) + return [f"`{branch}`", badges] + + table = generator.create_table( + rows=rows, + column_headers=["Branch", "Actions"], + row_formatter=formatter + ) + + # 4. High-level helpers (optional): + # - create_backport_table() - convenience wrapper for backport use case + # - generate_comment() - generate complete PR comment +""" + +import json +import os +import sys +from urllib.parse import urlencode, quote +from typing import Dict, List, Optional, Literal + + +class BadgeGenerator: + """Generator for GitHub Actions workflow trigger badges""" + + def __init__( + self, + app_domain: str, + repo_owner: str, + repo_name: str, + pr_number: Optional[int] = None, + pr_branch: Optional[str] = None, + base_branch: Optional[str] = None + ): + """ + Initialize badge generator + + Args: + app_domain: Base URL of the workflow executor app (e.g., "https://app.example.com") + repo_owner: Repository owner + repo_name: Repository name + pr_number: Optional PR number (for generating return_url) + pr_branch: Optional PR branch name + base_branch: Optional base branch name + """ + self.app_domain = app_domain.rstrip('/') + self.repo_owner = repo_owner + self.repo_name = repo_name + self.pr_number = pr_number + self.pr_branch = pr_branch + self.base_branch = base_branch or "main" + + # Generate return_url if pr_number is provided + self.return_url = None + if pr_number: + self.return_url = f"https://github.com/{repo_owner}/{repo_name}/pull/{pr_number}" + + def build_workflow_url( + self, + workflow_id: str, + link_type: Literal["direct", "ui"] = "direct", + ref: Optional[str] = None, + inputs: Optional[Dict[str, str]] = None, + return_url: Optional[str] = None, + **kwargs + ) -> str: + """ + Build workflow trigger URL + + Args: + workflow_id: Workflow file name (e.g., "test.yml") + link_type: "direct" for immediate execution, "ui" for form preview + ref: Branch or tag name (defaults to base_branch) + inputs: Dictionary of workflow input parameters + return_url: URL to return to after workflow execution + **kwargs: Additional query parameters + + Returns: + Complete workflow trigger URL + """ + ref = ref or self.base_branch + inputs = inputs or {} + + # Base parameters + params = { + "owner": self.repo_owner, + "repo": self.repo_name, + "workflow_id": workflow_id, + "ref": ref + } + + # Add workflow inputs + params.update(inputs) + + # Add return_url if provided + if return_url: + params["return_url"] = return_url + elif self.return_url: + params["return_url"] = self.return_url + + # Add UI flag if needed + if link_type == "ui": + params["ui"] = "true" + + # Add any additional parameters + params.update(kwargs) + + # Build URL + query_string = urlencode(params, doseq=True) + return f"{self.app_domain}/workflow/trigger?{query_string}" + + def create_badge( + self, + text: str, + workflow_id: str, + link_type: Literal["direct", "ui"] = "direct", + ref: Optional[str] = None, + inputs: Optional[Dict[str, str]] = None, + return_url: Optional[str] = None, + badge_color: str = "2196f3", + badge_style: str = "flat-square", + icon: Optional[str] = None, + **kwargs + ) -> str: + """ + Create a single badge markdown link + + Args: + text: Badge text to display + workflow_id: Workflow file name + link_type: "direct" or "ui" + ref: Branch name + inputs: Workflow input parameters + return_url: Return URL + badge_color: Badge color (hex without #) + badge_style: Badge style (flat-square, flat, plastic, etc.) + icon: Optional icon emoji or text prefix + **kwargs: Additional URL parameters + + Returns: + Markdown badge link + """ + url = self.build_workflow_url( + workflow_id=workflow_id, + link_type=link_type, + ref=ref, + inputs=inputs, + return_url=return_url, + **kwargs + ) + + # Prepare badge text + badge_text = f"{icon} {text}".strip() if icon else text + # Replace spaces with underscores for badge URL + badge_text_encoded = badge_text.replace(" ", "_") + + # Build shields.io badge URL + badge_url = ( + f"https://img.shields.io/badge/{quote(badge_text_encoded)}-{badge_color}" + f"?style={badge_style}" + ) + + return f"[![{badge_text}]({badge_url})]({url})" + + def create_badge_pair( + self, + text: str, + workflow_id: str, + ref: Optional[str] = None, + inputs: Optional[Dict[str, str]] = None, + return_url: Optional[str] = None, + direct_color: str = "4caf50", + ui_color: str = "ff9800", + badge_style: str = "flat-square", + icon: Optional[str] = None, + **kwargs + ) -> str: + """ + Create a pair of badges (direct + UI) + + Args: + text: Badge text + workflow_id: Workflow file name + ref: Branch name + inputs: Workflow inputs + return_url: Return URL + direct_color: Color for direct badge + ui_color: Color for UI badge + badge_style: Badge style + icon: Optional icon + **kwargs: Additional parameters + + Returns: + Two badges separated by space + """ + direct_badge = self.create_badge( + text=text, + workflow_id=workflow_id, + link_type="direct", + ref=ref, + inputs=inputs, + return_url=return_url, + badge_color=direct_color, + badge_style=badge_style, + icon=icon, + **kwargs + ) + + ui_badge = self.create_badge( + text="⚙️", + workflow_id=workflow_id, + link_type="ui", + ref=ref, + inputs=inputs, + return_url=return_url, + badge_color=ui_color, + badge_style=badge_style, + **kwargs + ) + + return f"{direct_badge} {ui_badge}" + + def create_table( + self, + rows: List[Dict], + column_headers: List[str], + row_formatter: callable, + return_url: Optional[str] = None + ) -> str: + """ + Create a markdown table with badges for different row values + + Args: + rows: List of dictionaries, each representing a table row + column_headers: List of column header names + row_formatter: Function that takes (row_data, generator) and returns list of cell markdown strings + return_url: Return URL + + Returns: + Markdown table string + + Example: + rows = [ + {"branch": "release/v1.0", "workflow_id": "backport.yml", "inputs": {...}}, + {"branch": "stable", "workflow_id": "backport.yml", "inputs": {...}} + ] + + def formatter(row, gen): + branch = row["branch"] + inputs = row["inputs"] + workflow_id = row["workflow_id"] + badges = gen.create_badge_pair("Backport", workflow_id, inputs=inputs) + return [f"`{branch}`", badges] + + table = generator.create_table( + rows=rows, + column_headers=["Branch", "Actions"], + row_formatter=formatter + ) + """ + if not rows: + return "" + + # Build header + header = "| " + " | ".join(column_headers) + " |\n" + separator = "|" + "|".join(["--------" for _ in column_headers]) + "|\n" + table = header + separator + + # Build rows + for row in rows: + cells = row_formatter(row, self) + if len(cells) != len(column_headers): + raise ValueError(f"Row formatter returned {len(cells)} cells, expected {len(column_headers)}") + table += "| " + " | ".join(cells) + " |\n" + + return table + + # ============================================================================ + # HIGH-LEVEL HELPERS (Optional convenience functions) + # ============================================================================ + + def create_backport_table( + self, + workflow_id: str, + source_branch: Optional[str] = None, + target_branches: List[str] = None, + return_url: Optional[str] = None, + direct_color: str = "2196f3", + ui_color: str = "ff9800", + badge_style: str = "flat-square", + **kwargs + ) -> str: + """ + Create a markdown table with backport badges for multiple branches + + HIGH-LEVEL HELPER: This is a convenience wrapper around create_table() + for the specific backport use case. For other use cases, use create_table() directly. + + Args: + workflow_id: Workflow file name (e.g., "backport.yml") + source_branch: Source branch to backport from + target_branches: List of target branches + return_url: Return URL + direct_color: Color for direct badges + ui_color: Color for UI badges + badge_style: Badge style + **kwargs: Additional workflow inputs + + Returns: + Markdown table string + """ + if not target_branches: + return "" + + source_branch = source_branch or self.pr_branch or self.base_branch + + # Prepare rows + rows = [] + for target_branch in target_branches: + rows.append({ + "branch": target_branch, + "workflow_id": workflow_id, + "source_branch": source_branch, + "target_branch": target_branch, + "return_url": return_url or self.return_url, + "direct_color": direct_color, + "ui_color": ui_color, + "badge_style": badge_style, + "base_branch": self.base_branch, + **kwargs + }) + + # Formatter function + def formatter(row, gen): + branch = row["branch"] + workflow_id = row["workflow_id"] + inputs = { + "source_branch": row["source_branch"], + "target_branch": row["target_branch"], + **{k: v for k, v in row.items() if k not in [ + "branch", "workflow_id", "source_branch", "target_branch", + "return_url", "direct_color", "ui_color", "badge_style", "base_branch" + ]} + } + badges = gen.create_badge_pair( + text="▶ Backport", + workflow_id=workflow_id, + ref=row["base_branch"], + inputs=inputs, + return_url=row["return_url"], + direct_color=row["direct_color"], + ui_color=row["ui_color"], + badge_style=row["badge_style"] + ) + return [f"`{branch}`", badges] + + return self.create_table( + rows=rows, + column_headers=["Branch", "Actions"], + row_formatter=formatter, + return_url=return_url + ) + + def generate_comment( + self, + workflows: List[Dict], + backport_branches: Optional[List[str]] = None, + backport_workflow_id: str = "backport.yml", + header: str = "## 🚀 Quick Actions", + footer: Optional[str] = None, + show_legend: bool = True + ) -> str: + """ + Generate a complete PR comment with badges (OPTIONAL high-level helper) + + Note: This is a convenience function. For more control, use low-level + functions (create_badge, create_badge_pair, create_table) directly + or use generate_markdown.py wrapper script. + + Args: + workflows: List of workflow configurations, each with: + - title: Section title + - workflow_id: Workflow file name + - ref: Branch name (optional) + - inputs: Workflow inputs (optional) + - badge_color: Badge color (optional) + - icon: Icon emoji (optional) + backport_branches: List of branches for backport table + backport_workflow_id: Workflow ID for backport + header: Comment header text + footer: Optional footer text + show_legend: Whether to show badge legend + + Returns: + Complete markdown comment + """ + lines = [header, ""] + + # Add workflows + for workflow_config in workflows: + title = workflow_config.get("title", "Workflow") + workflow_id = workflow_config.get("workflow_id") + ref = workflow_config.get("ref", self.base_branch) + inputs = workflow_config.get("inputs", {}) + badge_color = workflow_config.get("badge_color", "4caf50") + icon = workflow_config.get("icon", "▶") + + if not workflow_id: + continue + + lines.append(f"### {title}") + + badge_pair = self.create_badge_pair( + text=title.replace("### ", "").strip(), + workflow_id=workflow_id, + ref=ref, + inputs=inputs, + return_url=self.return_url, + direct_color=badge_color, + icon=icon + ) + + lines.append(badge_pair) + lines.append("") + + # Add backport table if branches provided + if backport_branches: + lines.append("### 📦 Backport") + lines.append("") + backport_table = self.create_backport_table( + workflow_id=backport_workflow_id, + source_branch=self.pr_branch, + target_branches=backport_branches, + return_url=self.return_url + ) + lines.append(backport_table) + lines.append("") + + # Add legend + if show_legend: + lines.append( + "▶ - immediately runs the workflow with default parameters.\n" + "⚙️ - opens UI to review and modify parameters before running.\n" + ) + + # Add footer + if footer: + lines.append("---") + lines.append(footer) + else: + lines.append("---") + lines.append("*These links will automatically comment on this PR with the workflow results.*") + lines.append("") + lines.append("*Tip: To open links in a new tab, use Ctrl+Click (Windows/Linux) or Cmd+Click (macOS).*") + + return "\n".join(lines) + + +def main(): + """ + CLI interface for badge generation (OPTIONAL) + + Note: For GitHub Actions workflows, use generate_markdown.py instead. + This CLI is provided for convenience and uses the high-level generate_comment() helper. + """ + import argparse + + parser = argparse.ArgumentParser(description="Generate GitHub Actions workflow badges") + parser.add_argument("--app-domain", required=True, help="Base URL of workflow executor app") + parser.add_argument("--repo-owner", required=True, help="Repository owner") + parser.add_argument("--repo-name", required=True, help="Repository name") + parser.add_argument("--pr-number", type=int, help="PR number (for return_url)") + parser.add_argument("--pr-branch", help="PR branch name") + parser.add_argument("--base-branch", default="main", help="Base branch name") + parser.add_argument("--config", help="Path to JSON config file") + parser.add_argument("--output", help="Output file path (default: stdout)") + + args = parser.parse_args() + + # Load config if provided + config = {} + if args.config and os.path.exists(args.config): + with open(args.config, 'r') as f: + config = json.load(f) + + # Initialize generator + generator = BadgeGenerator( + app_domain=args.app_domain, + repo_owner=args.repo_owner, + repo_name=args.repo_name, + pr_number=args.pr_number or config.get("pr_number"), + pr_branch=args.pr_branch or config.get("pr_branch"), + base_branch=args.base_branch or config.get("base_branch", "main") + ) + + # Generate comment + workflows = config.get("workflows", []) + backport_branches = config.get("backport_branches", []) + backport_workflow_id = config.get("backport_workflow_id", "backport.yml") + + comment = generator.generate_comment( + workflows=workflows, + backport_branches=backport_branches, + backport_workflow_id=backport_workflow_id + ) + + # Output + if args.output: + with open(args.output, 'w') as f: + f.write(comment) + else: + print(comment) + + +if __name__ == "__main__": + main() + diff --git a/.github/scripts/badges/generate_markdown.py b/.github/scripts/badges/generate_markdown.py new file mode 100755 index 0000000..7d47aad --- /dev/null +++ b/.github/scripts/badges/generate_markdown.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Generate markdown text with badges + +This script uses the low-level BadgeGenerator functions to build +markdown content with badges. Can be used for PR comments, issue bodies, +or any other markdown content. +""" + +import json +import sys +import os +from pathlib import Path + +# Add badges directory to path for imports +badges_dir = Path(__file__).parent +sys.path.insert(0, str(badges_dir)) + +from generate_badges import BadgeGenerator + + +def generate_markdown(config_path: str, app_domain: str, repo_owner: str, repo_name: str, + pr_number: int, pr_branch: str, base_branch: str) -> str: + """ + Generate markdown text with workflow trigger badges + + Args: + config_path: Path to JSON config file + app_domain: Base URL of workflow executor app + repo_owner: Repository owner + repo_name: Repository name + pr_number: PR number (used for return_url) + pr_branch: PR branch name (used for workflow inputs) + base_branch: Base branch name (used as default ref) + + Returns: + Complete markdown text with badges + """ + # Load config + with open(config_path, 'r') as f: + config = json.load(f) + + # Initialize generator + generator = BadgeGenerator( + app_domain=app_domain, + repo_owner=repo_owner, + repo_name=repo_name, + pr_number=pr_number, + pr_branch=pr_branch, + base_branch=base_branch + ) + + # Build markdown sections + lines = [] + + # Get blocks from config + blocks = config.get("blocks", []) + + if not blocks: + return "" + + # Set default order and type for blocks + for block in blocks: + if "order" not in block: + block["order"] = 100 # Default order + + if "type" not in block: + block["type"] = "text" # Default type + + # Sort all blocks by order + blocks.sort(key=lambda x: x.get("order", 100)) + + # Process all blocks in order + for block in blocks: + if not block.get("enabled", True): + continue + + block_type = block.get("type", "text") + + if block_type == "text": + # Text block + if block.get("separator", False): + lines.append("---") + + block_text = block.get("text", []) + if block_text: + if isinstance(block_text, list): + lines.extend(block_text) + else: + lines.append(block_text) + lines.append("") + + elif block_type == "badge": + # Badge block + title = block.get("title", "Workflow") + workflow_id = block.get("workflow_id") + ref = block.get("ref", base_branch) + base_inputs = block.get("inputs", {}).copy() # Copy to avoid modifying original + + # Replace dynamic values in inputs + if "pr_branch" in base_inputs: + base_inputs["pr_branch"] = pr_branch + if "from_pr" in base_inputs: + base_inputs["from_pr"] = str(pr_number) + + # Handle preset_branches - load branches from file and add to inputs + if "preset_branches" in base_inputs: + preset_branches_file = base_inputs.pop("preset_branches") # Remove from inputs + # Load branches from file + config_dir = os.path.dirname(os.path.abspath(config_path)) + branches_file_path = os.path.join(config_dir, preset_branches_file) + if os.path.exists(branches_file_path): + with open(branches_file_path, 'r') as f: + branches = json.load(f) + if isinstance(branches, dict): + branches = branches.get("branches", []) + # Add branches as comma-separated string for URL + if branches: + base_inputs["available_branches"] = ",".join(branches) + else: + # Try relative to current working directory + if os.path.exists(preset_branches_file): + with open(preset_branches_file, 'r') as f: + branches = json.load(f) + if isinstance(branches, dict): + branches = branches.get("branches", []) + if branches: + base_inputs["available_branches"] = ",".join(branches) + + badge_color = block.get("badge_color", "4caf50") + icon = block.get("icon", "▶") + badge_type = block.get("badge_type", "pair") # "pair" or "table" + + if not workflow_id: + continue + + # Add section header (unless hide_title is set) + if not block.get("hide_title", False): + lines.append(f"### {title}") + + if badge_type == "table": + # Generate table with multiple rows + rows_data = block.get("rows", []) + branches = block.get("branches", []) + branches_file = block.get("branches_file") + + # Load branches from file if specified + if branches_file and not rows_data: + config_dir = os.path.dirname(os.path.abspath(config_path)) + branches_file_path = os.path.join(config_dir, branches_file) + if os.path.exists(branches_file_path): + with open(branches_file_path, 'r') as f: + branches = json.load(f) + # Support both array of strings and object with branches array + if isinstance(branches, dict): + branches = branches.get("branches", []) + else: + # Try relative to current working directory + if os.path.exists(branches_file): + with open(branches_file, 'r') as f: + branches = json.load(f) + if isinstance(branches, dict): + branches = branches.get("branches", []) + + # If branches is specified, auto-generate rows + if branches and not rows_data: + label_key = block.get("label_key", "branch") + badge_text = block.get("badge_text", "Backport") + for branch in branches: + branch_inputs = { + "source_branch": pr_branch, + "target_branch": branch + } + # Merge with base inputs + branch_inputs = {**base_inputs, **branch_inputs} + rows_data.append({ + label_key: f"`{branch}`", + "badge_text": badge_text, + "inputs": branch_inputs + }) + + column_headers = block.get("column_headers", ["Type", "Actions"]) + label_key = block.get("label_key", "label") + + if rows_data: + lines.append("") + + # Prepare rows for create_table + rows = [] + for row_data in rows_data: + row_inputs = {**base_inputs, **row_data.get("inputs", {})} + row_badge_color = row_data.get("badge_color", badge_color) + row_icon = row_data.get("icon", icon) + + rows.append({ + "label": row_data.get(label_key, ""), + "workflow_id": workflow_id, + "ref": ref, + "inputs": row_inputs, + "badge_color": row_badge_color, + "icon": row_icon, + "return_url": generator.return_url, + "badge_text": row_data.get("badge_text") + }) + + # Formatter function + def formatter(row, gen): + label = row["label"] + badge_text = row.get("badge_text") or f"Run {label}" + badges = gen.create_badge_pair( + text=badge_text, + workflow_id=row["workflow_id"], + ref=row["ref"], + inputs=row["inputs"], + return_url=row["return_url"], + direct_color=row["badge_color"], + icon=row["icon"] + ) + label_formatted = label + if not label.startswith("`") and not label.startswith("**"): + label_formatted = f"**{label}**" + return [label_formatted, badges] + + table = generator.create_table( + rows=rows, + column_headers=column_headers, + row_formatter=formatter + ) + lines.append(table) + else: + # Generate badge pair (or single UI badge if only_ui is specified) + badge_text = title.replace("### ", "").replace("🧪 ", "").replace("🔨 ", "").replace("📦 ", "").strip() + only_ui = block.get("only_ui", False) + + if only_ui: + # Create only UI badge + ui_badge = generator.create_badge( + text=badge_text, + workflow_id=workflow_id, + link_type="ui", + ref=ref, + inputs=base_inputs, + return_url=generator.return_url, + badge_color=badge_color, + icon=icon + ) + lines.append(ui_badge) + else: + # Create badge pair (direct + UI) + badge_pair = generator.create_badge_pair( + text=badge_text, + workflow_id=workflow_id, + ref=ref, + inputs=base_inputs, + return_url=generator.return_url, + direct_color=badge_color, + icon=icon + ) + lines.append(badge_pair) + + lines.append("") + + return "\n".join(lines) + + +def main(): + """CLI interface""" + import argparse + + parser = argparse.ArgumentParser(description="Generate markdown text with badges") + parser.add_argument("--config", required=True, help="Path to JSON config file") + parser.add_argument("--app-domain", required=True, help="Base URL of workflow executor app") + parser.add_argument("--repo-owner", required=True, help="Repository owner") + parser.add_argument("--repo-name", required=True, help="Repository name") + parser.add_argument("--pr-number", type=int, required=True, help="PR number") + parser.add_argument("--pr-branch", required=True, help="PR branch name") + parser.add_argument("--base-branch", required=True, help="Base branch name") + parser.add_argument("--output", help="Output file path (default: stdout)") + + args = parser.parse_args() + + markdown = generate_markdown( + config_path=args.config, + app_domain=args.app_domain, + repo_owner=args.repo_owner, + repo_name=args.repo_name, + pr_number=args.pr_number, + pr_branch=args.pr_branch, + base_branch=args.base_branch + ) + + if args.output: + with open(args.output, 'w') as f: + f.write(markdown) + else: + print(markdown) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/badges/preview/preview_config.py b/.github/scripts/badges/preview/preview_config.py new file mode 100755 index 0000000..6a8a4e3 --- /dev/null +++ b/.github/scripts/badges/preview/preview_config.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Preview badge configuration + +Simple script to preview how your JSON config will look as markdown. +Uses sensible defaults so you can just edit JSON and preview it. +""" + +import json +import sys +import argparse +from pathlib import Path + +# Add badges directory to path (parent of preview) +badges_dir = Path(__file__).parent.parent +sys.path.insert(0, str(badges_dir)) + +from generate_markdown import generate_markdown + + +def main(): + parser = argparse.ArgumentParser( + description="Preview badge configuration markdown", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Preview with defaults (for quick testing) + python3 preview_config.py my_config.json + + # Preview with custom app domain + python3 preview_config.py my_config.json --app-domain https://my-app.com + + # Save preview to file + python3 preview_config.py my_config.json --output preview.md + + # Full customization + python3 preview_config.py my_config.json \\ + --app-domain https://my-app.com \\ + --repo-owner owner \\ + --repo-name repo \\ + --pr-number 123 \\ + --pr-branch feature \\ + --base-branch main + """ + ) + + parser.add_argument( + "config", + help="Path to JSON config file" + ) + parser.add_argument( + "--app-domain", + default="https://ydb-tech-qa.duckdns.org", + help="Base URL of workflow executor app (default: https://your-app.com)" + ) + parser.add_argument( + "--repo-owner", + default="owner", + help="Repository owner (default: owner)" + ) + parser.add_argument( + "--repo-name", + default="repo", + help="Repository name (default: repo)" + ) + parser.add_argument( + "--pr-number", + type=int, + default=123, + help="PR number (default: 123)" + ) + parser.add_argument( + "--pr-branch", + default="feature-branch", + help="PR branch name (default: feature-branch)" + ) + parser.add_argument( + "--base-branch", + default="main", + help="Base branch name (default: main)" + ) + parser.add_argument( + "--output", "-o", + help="Output file path (default: print to stdout)" + ) + + args = parser.parse_args() + + # Check if config file exists + config_path = Path(args.config) + if not config_path.exists(): + print(f"Error: Config file not found: {args.config}", file=sys.stderr) + return 1 + + # Validate JSON + try: + with open(config_path, 'r') as f: + config = json.load(f) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in config file: {e}", file=sys.stderr) + return 1 + + # Generate markdown + try: + markdown = generate_markdown( + config_path=str(config_path), + app_domain=args.app_domain, + repo_owner=args.repo_owner, + repo_name=args.repo_name, + pr_number=args.pr_number, + pr_branch=args.pr_branch, + base_branch=args.base_branch + ) + except Exception as e: + print(f"Error generating markdown: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + return 1 + + # Output + if args.output: + output_path = Path(args.output) + output_path.write_text(markdown, encoding='utf-8') + print(f"✓ Preview saved to: {output_path}", file=sys.stderr) + print(f"✓ Preview length: {len(markdown)} characters", file=sys.stderr) + else: + print(markdown) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/.github/workflows/pr-badges.yml b/.github/workflows/pr-badges.yml index a9259f5..3c92007 100644 --- a/.github/workflows/pr-badges.yml +++ b/.github/workflows/pr-badges.yml @@ -18,15 +18,10 @@ jobs: with: fetch-depth: 1 - - name: Read backport branches config - id: read_config - run: | - if [ -f ".github/configs/backport_branches.json" ]; then - cat .github/configs/backport_branches.json - else - echo '["release/v1.0"]' # Default fallback - fi > backport_branches.json - echo "branches_file=backport_branches.json" >> $GITHUB_OUTPUT + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' - name: Check if badge comment already exists id: check_comment @@ -50,7 +45,23 @@ jobs: core.setOutput('comment_id', badgeComment ? badgeComment.id : ''); core.setOutput('exists', badgeComment ? 'true' : 'false'); - - name: Add or update badge comment + - name: Generate badge comment + id: generate_comment + run: | + # Build comment using wrapper script + python3 .github/scripts/badges/generate_markdown.py \ + --config .github/scripts/badges/configs/badge_config.json \ + --app-domain "${{ env.APP_DOMAIN }}" \ + --repo-owner "${{ github.repository_owner }}" \ + --repo-name "${{ github.event.repository.name }}" \ + --pr-number ${{ github.event.pull_request.number }} \ + --pr-branch "${{ github.event.pull_request.head.ref }}" \ + --base-branch "${{ github.event.pull_request.base.ref }}" \ + --output comment.txt + + echo "comment_file=comment.txt" >> $GITHUB_OUTPUT + + - name: Add badge comment if: steps.check_comment.outputs.exists == 'false' uses: actions/github-script@v7 with: @@ -58,56 +69,8 @@ jobs: script: | const fs = require('fs'); const prNumber = context.payload.pull_request.number; - const prBranch = context.payload.pull_request.head.ref; - const baseBranch = context.payload.pull_request.base.ref; - - // Get app domain from environment variable - // Set APP_DOMAIN in: Settings → Secrets and variables → Actions → Variables - let appDomain = process.env.APP_DOMAIN && process.env.APP_DOMAIN.trim() !== '' - ? process.env.APP_DOMAIN.trim() - : 'http://localhost:8000'; - // Remove trailing slash to avoid double slashes in URL - appDomain = appDomain.replace(/\/+$/, ''); - - // Read backport branches from config file - let backportBranches = ['release/v1.0']; // Default fallback - try { - const configContent = fs.readFileSync('backport_branches.json', 'utf8'); - backportBranches = JSON.parse(configContent); - } catch (error) { - console.log('Failed to read backport_branches.json, using default:', error.message); - } - - const repoOwner = context.repo.owner; - const repoName = context.repo.repo; - - // Build URLs for test workflow - const testUrlDirect = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=test.yml&ref=${baseBranch}&pr_branch=${prBranch}&test_type=all&from_pr=${prNumber}`; - const testUrlUI = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=test.yml&ref=${baseBranch}&pr_branch=${prBranch}&test_type=all&from_pr=${prNumber}&ui=true`; - - // Build backport table with buttons for each branch - let backportTable = '| Branch | Actions |\n|--------|---------|\n'; - for (const targetBranch of backportBranches) { - const backportUrlDirect = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=backport.yml&ref=${baseBranch}&source_branch=${prBranch}&target_branch=${targetBranch}&from_pr=${prNumber}`; - const backportUrlUI = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=backport.yml&ref=${baseBranch}&source_branch=${prBranch}&target_branch=${targetBranch}&from_pr=${prNumber}&ui=true`; - - backportTable += `| \`${targetBranch}\` | ` + - `[![▶ Backport](https://img.shields.io/badge/▶_Backport-2196f3?style=flat-square)](${backportUrlDirect}) ` + - `[![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](${backportUrlUI}) |\n`; - } + const comment = fs.readFileSync('${{ steps.generate_comment.outputs.comment_file }}', 'utf8'); - const comment = '## 🚀 Quick Actions\n\n' + - 'Use these buttons to quickly run workflows for this PR:\n\n' + - '### 🧪 Run Tests\n' + - `[![▶ Run Tests](https://img.shields.io/badge/▶_Run_Tests-4caf50?style=flat-square)](${testUrlDirect}) ` + - `[![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](${testUrlUI})\n\n` + - '### 📦 Backport\n\n' + - backportTable + '\n' + - '▶ - immediately runs the workflow with default parameters.\n' + - '⚙️ - opens UI to review and modify parameters before running.\n\n' + - '---\n' + - '*These links will automatically comment on this PR with the workflow results.*'; - await github.rest.issues.createComment({ issue_number: prNumber, owner: context.repo.owner, @@ -123,61 +86,12 @@ jobs: script: | const fs = require('fs'); const prNumber = context.payload.pull_request.number; - const prBranch = context.payload.pull_request.head.ref; - const baseBranch = context.payload.pull_request.base.ref; const commentId = parseInt('${{ steps.check_comment.outputs.comment_id }}'); + const comment = fs.readFileSync('${{ steps.generate_comment.outputs.comment_file }}', 'utf8'); - // Get app domain from environment variable - // Set APP_DOMAIN in: Settings → Secrets and variables → Actions → Variables - let appDomain = process.env.APP_DOMAIN && process.env.APP_DOMAIN.trim() !== '' - ? process.env.APP_DOMAIN.trim() - : 'http://localhost:8000'; - // Remove trailing slash to avoid double slashes in URL - appDomain = appDomain.replace(/\/+$/, ''); - - // Read backport branches from config file - let backportBranches = ['release/v1.0']; // Default fallback - try { - const configContent = fs.readFileSync('backport_branches.json', 'utf8'); - backportBranches = JSON.parse(configContent); - } catch (error) { - console.log('Failed to read backport_branches.json, using default:', error.message); - } - - const repoOwner = context.repo.owner; - const repoName = context.repo.repo; - - // Build URLs for test workflow - const testUrlDirect = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=test.yml&ref=${baseBranch}&pr_branch=${prBranch}&test_type=all&from_pr=${prNumber}`; - const testUrlUI = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=test.yml&ref=${baseBranch}&pr_branch=${prBranch}&test_type=all&from_pr=${prNumber}&ui=true`; - - // Build backport table with buttons for each branch - let backportTable = '| Branch | Actions |\n|--------|---------|\n'; - for (const targetBranch of backportBranches) { - const backportUrlDirect = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=backport.yml&ref=${baseBranch}&source_branch=${prBranch}&target_branch=${targetBranch}&from_pr=${prNumber}`; - const backportUrlUI = `${appDomain}/workflow/trigger?owner=${repoOwner}&repo=${repoName}&workflow_id=backport.yml&ref=${baseBranch}&source_branch=${prBranch}&target_branch=${targetBranch}&from_pr=${prNumber}&ui=true`; - - backportTable += `| \`${targetBranch}\` | ` + - `[![▶ Backport](https://img.shields.io/badge/▶_Backport-2196f3?style=flat-square)](${backportUrlDirect}) ` + - `[![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](${backportUrlUI}) |\n`; - } - - const comment = '## 🚀 Quick Actions\n\n' + - 'Use these buttons to quickly run workflows for this PR:\n\n' + - '### 🧪 Run Tests\n' + - `[![▶ Run Tests](https://img.shields.io/badge/▶_Run_Tests-4caf50?style=flat-square)](${testUrlDirect}) ` + - `[![⚙️](https://img.shields.io/badge/⚙️-ff9800?style=flat-square)](${testUrlUI})\n\n` + - '### 📦 Backport\n\n' + - backportTable + '\n' + - '▶ - immediately runs the workflow with default parameters.\n' + - '⚙️ - opens UI to review and modify parameters before running.\n\n' + - '---\n' + - '*These links will automatically comment on this PR with the workflow results.*'; - await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: commentId, body: comment }); - diff --git a/app.py b/app.py index 3b1aefb..39a6954 100644 --- a/app.py +++ b/app.py @@ -84,10 +84,13 @@ async def root( default_workflow_id = workflow_id or os.getenv("DEFAULT_WORKFLOW_ID", "") default_ref = ref or "main" + # Извлекаем return_url отдельно + return_url = request.query_params.get("return_url") + # Извлекаем все остальные параметры для предзаполнения workflow inputs workflow_inputs = {} query_params = dict(request.query_params) - excluded_params = {"owner", "repo", "workflow_id", "ref"} + excluded_params = {"owner", "repo", "workflow_id", "ref", "return_url"} for key, value in query_params.items(): if key not in excluded_params and value: workflow_inputs[key] = value @@ -114,6 +117,7 @@ async def root( "default_workflow_id": default_workflow_id, "default_ref": default_ref, "workflow_inputs": workflow_inputs, # Параметры для предзаполнения workflow inputs + "return_url": return_url, # URL для возврата "workflows": workflows_list, "auto_open_run": config.AUTO_OPEN_RUN } diff --git a/backend/routes/workflow.py b/backend/routes/workflow.py index 95bc582..6728362 100644 --- a/backend/routes/workflow.py +++ b/backend/routes/workflow.py @@ -3,6 +3,7 @@ """ import os import logging +from urllib.parse import quote from fastapi import APIRouter, Request, HTTPException, Form, Query from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -15,6 +16,13 @@ logger = logging.getLogger(__name__) router = APIRouter() templates = Jinja2Templates(directory="frontend/templates") +# Add urlencode filter to Jinja2 +def urlencode_filter(value): + """URL encode filter for Jinja2 templates""" + if value is None: + return "" + return quote(str(value), safe="") +templates.env.filters["urlencode"] = urlencode_filter async def _trigger_and_show_result( @@ -24,7 +32,8 @@ async def _trigger_and_show_result( workflow_id: str, ref: str, inputs: dict, - return_json: bool = False + return_json: bool = False, + return_url: str = None ): """Вспомогательная функция для запуска workflow и показа результата""" # Check authentication @@ -68,7 +77,10 @@ async def _trigger_and_show_result( "error": error_msg, "owner": owner, "repo": repo, - "workflow_id": workflow_id + "workflow_id": workflow_id, + "ref": ref, + "inputs": inputs, + "return_url": return_url } ) # Prevent caching @@ -129,7 +141,8 @@ async def _trigger_and_show_result( "workflow_url": result.get("workflow_url"), "trigger_time": result.get("trigger_time"), "auto_open_run": config.AUTO_OPEN_RUN, - "error": result.get("message") if not result["success"] else None + "error": result.get("message") if not result["success"] else None, + "return_url": return_url } ) # Prevent caching to ensure workflow is triggered on each request @@ -152,7 +165,10 @@ async def _trigger_and_show_result( "error": str(e), "owner": owner, "repo": repo, - "workflow_id": workflow_id + "workflow_id": workflow_id, + "ref": ref, + "inputs": inputs, + "return_url": return_url } ) # Prevent caching @@ -195,9 +211,14 @@ async def trigger_workflow_get( if ref and ref != "main": params.append(f"ref={ref}") + # Добавляем return_url если есть + return_url = request.query_params.get("return_url") + if return_url: + params.append(f"return_url={return_url}") + # Добавляем все остальные параметры (workflow inputs) query_params = dict(request.query_params) - excluded_params = {"owner", "repo", "workflow_id", "ref", "ui", "tests"} + excluded_params = {"owner", "repo", "workflow_id", "ref", "ui", "tests", "return_url"} for key, value in query_params.items(): if key not in excluded_params and value: params.append(f"{key}={value}") @@ -224,7 +245,12 @@ async def trigger_workflow_get( "request": request, "user": request.session.get("user"), "success": False, - "error": error_msg + "error": error_msg, + "owner": owner or "", + "repo": repo or "", + "workflow_id": workflow_id or "", + "ref": ref or "", + "inputs": {} } ) # Prevent caching @@ -233,11 +259,14 @@ async def trigger_workflow_get( response.headers["Expires"] = "0" return response + # Extract return_url before parsing inputs + return_url = request.query_params.get("return_url") + # Parse inputs from query parameters # Все параметры кроме служебных считаются inputs inputs = {} query_params = dict(request.query_params) - excluded_params = {"owner", "repo", "workflow_id", "ref", "ui"} + excluded_params = {"owner", "repo", "workflow_id", "ref", "ui", "return_url"} for key, value in query_params.items(): if key not in excluded_params and value: @@ -248,7 +277,7 @@ async def trigger_workflow_get( inputs["tests"] = tests return await _trigger_and_show_result( - request, owner, repo, workflow_id, ref, inputs, return_json + request, owner, repo, workflow_id, ref, inputs, return_json, return_url ) @@ -278,16 +307,24 @@ async def trigger_workflow_post( "request": request, "user": request.session.get("user"), "success": False, - "error": "Repository owner, name, and workflow_id are required" + "error": "Repository owner, name, and workflow_id are required", + "owner": owner or "", + "repo": repo or "", + "workflow_id": workflow_id or "", + "ref": ref or "", + "inputs": {} } ) - # Получаем все inputs из формы (динамические поля) + # Extract return_url from form data form_data = await request.form() + return_url = form_data.get("return_url") + + # Получаем все inputs из формы (динамические поля) inputs = {} # Обрабатываем все поля кроме служебных - excluded_fields = {"owner", "repo", "workflow_id", "ref", "tests"} + excluded_fields = {"owner", "repo", "workflow_id", "ref", "tests", "return_url"} for key, value in form_data.items(): if key not in excluded_fields: # Обработка boolean полей - если значение есть, используем его @@ -303,6 +340,6 @@ async def trigger_workflow_post( inputs["tests"] = tests return await _trigger_and_show_result( - request, owner, repo, workflow_id, ref, inputs, return_json=False + request, owner, repo, workflow_id, ref, inputs, return_json=False, return_url=return_url ) diff --git a/frontend/templates/index.html b/frontend/templates/index.html index c8ca854..31ecb6b 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -47,6 +47,9 @@

Run GitHub Action

+ {% if return_url %} + + {% endif %}
@@ -847,10 +850,16 @@

Run GitHub Action

const formData = new FormData(form); for (const [key, value] of formData.entries()) { // Пропускаем служебные поля - if (!['owner', 'repo', 'workflow_id', 'ref'].includes(key) && value) { + if (!['owner', 'repo', 'workflow_id', 'ref', 'return_url'].includes(key) && value) { params.append(key, value); } } + + // Добавляем return_url отдельно, если есть + const returnUrlInput = form.querySelector('input[name="return_url"]'); + if (returnUrlInput && returnUrlInput.value) { + params.append('return_url', returnUrlInput.value); + } } } diff --git a/frontend/templates/result.html b/frontend/templates/result.html index 36f170d..f74a6c0 100644 --- a/frontend/templates/result.html +++ b/frontend/templates/result.html @@ -88,12 +88,17 @@

Success