Files
actions/actions/dispatch_actions.py
T

217 lines
8.2 KiB
Python

# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
from __future__ import annotations
import os
import time
from datetime import datetime, timezone
from .utils import ACTIONS_CREDIT, GITHUB_API_URL, Action
# Configuration
RUN_CI_KEYWORD = "@ultralytics/run-ci"
RUN_ALL_KEYWORD = "@ultralytics/run-all"
RUN_DOCKER_KEYWORD = "@ultralytics/run-docker"
WORKFLOW_FILES = {
RUN_CI_KEYWORD: ["ci.yml"],
RUN_ALL_KEYWORD: ["ci.yml", "docker.yml"],
RUN_DOCKER_KEYWORD: ["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", {})
# Check if PR is from a fork
if head.get("repo") and head["repo"]["id"] != pr_data["base"]["repo"]["id"]: # is from a fork
# Create temp branch in base repo by pushing fork code
temp_branch = f"temp-ci-{pr_number}-{int(time.time() * 1000)}"
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
def trigger_and_get_workflow_info(
event, branch: str, workflow_files: list[str], temp_branch: str | None = None
) -> list[dict]:
"""Triggers workflows and returns their information, deleting temp branch if provided."""
repo = event.repository
results = []
failed = {}
try:
# Trigger all workflows
for file in workflow_files:
response = event.post(
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/dispatches", json={"ref": branch}
)
if isinstance(response.status_code, int) and response.status_code != 204:
failed[file] = response.json().get("message", "workflow dispatch failed")
# Wait for workflows to be created and start
if len(failed) < len(workflow_files):
time.sleep(60)
# Collect information about all workflows
for file in workflow_files:
if file in failed:
results.append(
{
"name": file.replace(".yml", "").title(),
"file": file,
"url": f"https://github.com/{repo}/actions/workflows/{file}",
"run_number": None,
"error": failed[file],
}
)
continue
# Get workflow name
response = event.get(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}")
name = file.replace(".yml", "").title()
if response.status_code == 200:
name = response.json().get("name", name)
# Get run information
run_url = f"https://github.com/{repo}/actions/workflows/{file}"
run_number = None
runs_response = event.get(
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
)
if runs_response.status_code == 200 and (runs := runs_response.json().get("workflow_runs", [])):
run_url = runs[0].get("html_url", run_url)
run_number = runs[0].get("run_number")
results.append({"name": name, "file": file, "url": run_url, "run_number": run_number})
finally:
# Always delete temp branch even if workflow collection fails
if temp_branch:
event.delete(f"{GITHUB_API_URL}/repos/{repo}/git/refs/heads/{temp_branch}")
return results
def update_comment(event, comment_body: str, command: str, triggered_actions: list[dict]):
"""Updates the comment with workflow information."""
if not triggered_actions:
return
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
failed = all(action.get("error") for action in triggered_actions)
status = (
"No GitHub Actions workflows were started" if failed else "GitHub Actions below triggered via workflow dispatch"
)
summary = f"""
## ⚡ Actions Trigger
{ACTIONS_CREDIT}
{status} for this PR at {timestamp} with `{command}` command
(available commands are `{RUN_ALL_KEYWORD}`, `{RUN_CI_KEYWORD}`, and `{RUN_DOCKER_KEYWORD}`):
"""
for action in triggered_actions:
if error := action.get("error"):
guidance = (
f"If the PR branch was deleted, restore it or open a new PR from an existing branch, "
f"then run `{command}` again."
if "no ref found" in error.lower()
else f"Check the workflow dispatch configuration and permissions, then run `{command}` again."
)
summary += (
f"* ❌ [{action['name']}]({action['url']}): `{action['file']}` failed because `{error}`. {guidance}\n"
)
else:
run_info = f" run {action['run_number']}" if action["run_number"] else ""
summary += f"* ✅ [{action['name']}]({action['url']}): `{action['file']}`{run_info}\n"
new_body = comment_body.replace(command, summary).strip()
comment_id = event.event_data["comment"]["id"]
event.patch(f"{GITHUB_API_URL}/repos/{event.repository}/issues/comments/{comment_id}", json={"body": new_body})
def main(*args, **kwargs):
"""Handles triggering workflows from PR comments."""
event = Action(*args, **kwargs)
# Only process new comments on PRs
if (
event.event_name != "issue_comment"
or "pull_request" not in event.event_data.get("issue", {})
or event.event_data.get("action") != "created"
):
return
# Get comment info
comment_body = event.event_data["comment"].get("body") or ""
username = event.event_data["comment"]["user"]["login"]
command = next(
(
keyword
for keyword in WORKFLOW_FILES
if keyword in comment_body and comment_body.count(keyword) > comment_body.count(f"`{keyword}`")
),
None,
)
if not command or not event.is_org_member(username):
return
# Get branch, trigger workflows, and update comment
event.toggle_eyes_reaction(True)
branch, temp_branch = get_pr_branch(event)
print(f"Triggering workflows on branch: {branch}" + (" (temp)" if temp_branch else ""))
triggered_actions = trigger_and_get_workflow_info(event, branch, WORKFLOW_FILES[command], temp_branch)
update_comment(event, comment_body, command, triggered_actions)
event.toggle_eyes_reaction(False)
if __name__ == "__main__":
main()