From dc9b4a21be73986adbeb9b64191f6c97920f0c1c Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Fri, 24 Oct 2025 01:29:35 +0200 Subject: [PATCH] Workflow dispatch from PR forks (#607) Co-authored-by: UltralyticsAssistant --- actions/__init__.py | 2 +- actions/dispatch_actions.py | 60 +++++++++++++++++++++++++++------- actions/utils/github_utils.py | 4 +-- tests/test_dispatch_actions.py | 12 +++---- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/actions/__init__.py b/actions/__init__.py index 4879585..b3d6a97 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -23,4 +23,4 @@ # ├── test_summarize_pr.py # └── ... -__version__ = "0.1.9" +__version__ = "0.2.0" diff --git a/actions/dispatch_actions.py b/actions/dispatch_actions.py index 081a804..3dc89d0 100644 --- a/actions/dispatch_actions.py +++ b/actions/dispatch_actions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import time from datetime import datetime @@ -14,6 +15,9 @@ WORKFLOW_FILES = ["ci.yml", "docker.yml"] def get_pr_branch(event) -> tuple[str, str | None]: """Gets the PR branch name, creating temp branch for forks, returning (branch, temp_branch_to_delete).""" + import subprocess + import tempfile + pr_number = event.event_data["issue"]["number"] pr_data = event.get_repo_data(f"pulls/{pr_number}") head = pr_data.get("head", {}) @@ -22,12 +26,43 @@ def get_pr_branch(event) -> tuple[str, str | None]: is_fork = head.get("repo") and head["repo"]["id"] != pr_data["base"]["repo"]["id"] if is_fork: - # Create temp branch in base repo for fork PRs + # Create temp branch in base repo by pushing fork code temp_branch = f"temp-ci-{pr_number}-{int(time.time() * 1000)}" - repo = event.repository - event.post( - f"{GITHUB_API_URL}/repos/{repo}/git/refs", json={"ref": f"refs/heads/{temp_branch}", "sha": head["sha"]} - ) + fork_repo = head["repo"]["full_name"] + fork_branch = head["ref"] + base_repo = event.repository + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN environment variable is not set") + + with tempfile.TemporaryDirectory() as tmp_dir: + repo_dir = os.path.join(tmp_dir, "repo") + base_url = f"https://x-access-token:{token}@github.com/{base_repo}.git" + fork_url = f"https://github.com/{fork_repo}.git" + + try: + # Clone base repo (minimal) + subprocess.run(["git", "clone", "--depth", "1", base_url, repo_dir], check=True, capture_output=True) + + # Add fork as remote and fetch the PR branch + subprocess.run( + ["git", "remote", "add", "fork", fork_url], cwd=repo_dir, check=True, capture_output=True + ) + subprocess.run( + ["git", "fetch", "fork", f"{fork_branch}:{temp_branch}"], + cwd=repo_dir, + check=True, + capture_output=True, + ) + + # Push temp branch to base repo + subprocess.run(["git", "push", "origin", temp_branch], cwd=repo_dir, check=True, capture_output=True) + except subprocess.CalledProcessError as e: + # Sanitize error output to prevent token leakage + stderr = e.stderr.decode() if e.stderr else "No stderr output" + stderr = stderr.replace(token, "***TOKEN***") + raise RuntimeError(f"Failed to create tmp branch from fork (exit code {e.returncode}): {stderr}") from e + return temp_branch, temp_branch return head.get("ref", "main"), None @@ -81,12 +116,15 @@ def update_comment(event, comment_body: str, triggered_actions: list[dict], bran return timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC") - summary = ( - f"\n\n## ⚡ Actions Trigger\n\n" - f"Made with ❤️ by [Ultralytics Actions](https://www.ultralytics.com/actions)\n\n" - f"GitHub Actions below triggered via workflow dispatch on this " - f"PR branch `{branch}` at {timestamp} with `{RUN_CI_KEYWORD}` command:\n\n" - ) + summary = f""" + +## ⚡ Actions Trigger + +Made with ❤️ by [Ultralytics Actions](https://www.ultralytics.com/actions) + +GitHub Actions below triggered via workflow dispatch for this PR at {timestamp} with `{RUN_CI_KEYWORD}` command: + +""" for action in triggered_actions: run_info = f" run {action['run_number']}" if action["run_number"] else "" diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 6f062e9..d0647aa 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -122,8 +122,8 @@ class Action: self._pr_summary_cache = None self._username_cache = None self._default_status = { - "get": [200], - "post": [200, 201], + "get": [200, 204], + "post": [200, 201, 204], "put": [200, 201, 204], "patch": [200], "delete": [200, 204], diff --git a/tests/test_dispatch_actions.py b/tests/test_dispatch_actions.py index ffc91b2..af0a2d3 100644 --- a/tests/test_dispatch_actions.py +++ b/tests/test_dispatch_actions.py @@ -34,19 +34,19 @@ def test_get_pr_branch_fork(): mock_event.event_data = {"issue": {"number": 456}} mock_event.repository = "base/repo" mock_event.get_repo_data.return_value = { - "head": {"ref": "fork-branch", "sha": "abc123", "repo": {"id": 2}}, + "head": {"ref": "fork-branch", "sha": "abc123", "repo": {"id": 2, "full_name": "fork/repo"}}, "base": {"repo": {"id": 1}}, } with patch("time.time", return_value=1234567.890): - branch, temp_branch = get_pr_branch(mock_event) + with patch("subprocess.run") as mock_run: + with patch("os.environ.get", return_value="test-token"): + branch, temp_branch = get_pr_branch(mock_event) assert branch == "temp-ci-456-1234567890" assert temp_branch == "temp-ci-456-1234567890" - mock_event.post.assert_called_once_with( - "https://api.github.com/repos/base/repo/git/refs", - json={"ref": "refs/heads/temp-ci-456-1234567890", "sha": "abc123"}, - ) + # Verify git commands were called + assert mock_run.call_count == 4 # clone, remote add, fetch, push def test_trigger_and_get_workflow_info():