Files
cleveragents-core/tools/controller/master/ci_rerun.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

288 lines
8.9 KiB
Python

"""CI-rerun trigger — empty-commit push.
Forgejo ``15.0.2+gitea-1.22.0`` has NO Actions rerun API
(``POST /api/v1/repos/.../actions/runs/{id}/rerun`` → 404). The
controller therefore re-triggers CI the only reliable way: it pushes
an **empty commit** to the PR branch. Advancing HEAD fires a fresh
``pull_request`` CI run naturally, and the new head SHA makes the
fresh run unambiguous (no "is this the old run or the new one?"
race).
This module ships:
- ``CIRerunResult`` — the return shape (new head SHA on success,
``ok=False`` + an error string on failure).
- ``trigger_ci_rerun_via_empty_commit`` — the production helper. In an
isolated temp directory it shallow-clones the PR branch over a
token-credentialed HTTPS URL, runs ``git commit --allow-empty`` +
``git push``, and parses the new head SHA. Never raises into the
caller — every failure path returns ``CIRerunResult(ok=False, ...)``.
- ``make_ci_rerun_callback`` — closure factory binding the helper to a
config (mirrors how ``forgejo_http`` builds its other callbacks).
Tests inject a fake callback instead.
The credentialed-git pattern (``-c http.extraheader=Authorization:
token <PAT>`` so the token never lands in ``ps`` / the reflog / error
strings) is lifted from ``tools/merge_drive.py``'s ``_run_git`` /
``_git_extraheader_args``.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
import tempfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# trigger_ci_rerun(owner, repo, pr_branch) -> CIRerunResult.
# A CI-rerun callback the ci_gate tick invokes. Production wires this
# to ``trigger_ci_rerun_via_empty_commit``; tests inject a fake.
CIRerunCallback = Callable[[str, str, str], "CIRerunResult"]
_RERUN_COMMIT_MESSAGE = "chore: re-trigger CI [controller]"
@dataclass(frozen=True)
class CIRerunResult:
"""Outcome of a CI-rerun trigger.
Attributes:
ok: True when the empty commit was pushed successfully.
new_head_sha: the SHA of the empty commit (the new PR HEAD).
Populated only when ``ok`` is True.
error: a short failure description. Populated only when ``ok``
is False.
"""
ok: bool
new_head_sha: str | None = None
error: str | None = None
def _run_git(
args: list[str],
cwd: Path,
*,
check: bool = True,
timeout: int = 120,
) -> subprocess.CompletedProcess:
"""Run ``git -C <cwd> <args>`` capturing output. Mirrors
``merge_drive._run_git``."""
return subprocess.run(
["git", "-C", str(cwd), *args],
check=check,
capture_output=True,
text=True,
timeout=timeout,
)
def _extraheader_args(token: str) -> list[str]:
"""Build ``-c http.extraheader=Authorization: token <PAT>`` so the
token never appears in a remote URL (which would land in ``ps``,
the reflog, and error messages). Lifted from
``merge_drive._git_extraheader_args``."""
return ["-c", f"http.extraheader=Authorization: token {token}"]
def trigger_ci_rerun_via_empty_commit(
*,
owner: str,
repo: str,
pr_branch: str,
https_remote: str,
token: str,
git_user_name: str = "controller-ci-rerun",
git_user_email: str = "controller@cleverthis.com",
timeout_s: int = 120,
) -> CIRerunResult:
"""Push an empty commit to ``pr_branch`` to re-trigger CI.
In a fresh temp directory:
1. shallow-clone ``pr_branch`` over the token-credentialed HTTPS
remote;
2. ``git commit --allow-empty``;
3. ``git push``;
4. parse + return the new head SHA.
Args:
owner / repo: identify the PR's repo (used only for logging —
the actual clone target is ``https_remote``).
pr_branch: the PR's head branch name.
https_remote: the ``https://host/owner/repo.git`` clone URL.
token: the Forgejo PAT, passed via ``http.extraheader``.
git_user_name / git_user_email: identity for the empty commit.
timeout_s: per-git-call timeout.
Returns a :class:`CIRerunResult`. Never raises — every failure
(missing token, clone failure, push rejection, ...) degrades to
``CIRerunResult(ok=False, error=...)`` so the caller's tick is
never aborted.
"""
if not token:
return CIRerunResult(
ok=False,
error="no Forgejo token available for CI-rerun push",
)
if not https_remote:
return CIRerunResult(
ok=False,
error="no HTTPS remote URL configured for CI-rerun",
)
if not pr_branch:
return CIRerunResult(ok=False, error="no PR branch name provided")
tmp_root: str | None = None
try:
tmp_root = tempfile.mkdtemp(prefix="controller-ci-rerun-")
work_dir = Path(tmp_root) / "repo"
# 1. Shallow-clone just the PR branch.
subprocess.run(
[
"git",
*_extraheader_args(token),
"clone",
"--depth",
"1",
"--single-branch",
"--branch",
pr_branch,
https_remote,
str(work_dir),
],
check=True,
capture_output=True,
text=True,
timeout=timeout_s,
)
# Re-bind the extraheader at repo scope + identity so the
# subsequent commit/push calls work via plain ``git -C``.
_run_git(
["config", "http.extraheader", f"Authorization: token {token}"],
work_dir,
timeout=timeout_s,
)
_run_git(
["config", "user.name", git_user_name],
work_dir,
timeout=timeout_s,
)
_run_git(
["config", "user.email", git_user_email],
work_dir,
timeout=timeout_s,
)
# 2. Empty commit.
_run_git(
["commit", "--allow-empty", "-m", _RERUN_COMMIT_MESSAGE],
work_dir,
timeout=timeout_s,
)
# 3. Push.
_run_git(
["push", "origin", f"HEAD:{pr_branch}"],
work_dir,
timeout=timeout_s,
)
# 4. Parse the new head SHA.
rev = _run_git(
["rev-parse", "HEAD"],
work_dir,
timeout=timeout_s,
)
new_sha = rev.stdout.strip()
if not new_sha:
return CIRerunResult(
ok=False,
error="empty-commit push succeeded but rev-parse returned no SHA",
)
logger.info(
"ci_rerun: pushed empty commit to %s/%s@%s — new HEAD %s",
owner,
repo,
pr_branch,
new_sha[:12],
)
return CIRerunResult(ok=True, new_head_sha=new_sha)
except subprocess.CalledProcessError as exc:
# Truncate stderr — it can carry the remote URL but never the
# token (the token is in http.extraheader, not the URL).
stderr = (exc.stderr or "").strip()
if len(stderr) > 300:
stderr = stderr[:300] + ""
logger.warning(
"ci_rerun: git failed for %s/%s@%s: %s",
owner,
repo,
pr_branch,
stderr,
)
return CIRerunResult(
ok=False,
error=f"git failed: {stderr}" if stderr else "git command failed",
)
except subprocess.TimeoutExpired:
logger.warning(
"ci_rerun: git timed out for %s/%s@%s",
owner,
repo,
pr_branch,
)
return CIRerunResult(ok=False, error="git command timed out")
except Exception as exc: # noqa: BLE001 — never raise into the tick
logger.warning(
"ci_rerun: unexpected failure for %s/%s@%s: %s",
owner,
repo,
pr_branch,
exc,
)
return CIRerunResult(ok=False, error=f"unexpected failure: {exc}")
finally:
if tmp_root:
shutil.rmtree(tmp_root, ignore_errors=True)
def make_ci_rerun_callback(
*,
https_remote: str,
token: str,
timeout_s: int = 120,
) -> CIRerunCallback:
"""Build a ``CIRerunCallback`` bound to a remote + token.
Mirrors the closure-factory style of ``forgejo_http``'s callback
builders. Production passes the result to the ci_gate tick; tests
inject a fake callback instead.
"""
def trigger_ci_rerun(
owner: str,
repo: str,
pr_branch: str,
) -> CIRerunResult:
return trigger_ci_rerun_via_empty_commit(
owner=owner,
repo=repo,
pr_branch=pr_branch,
https_remote=https_remote,
token=token,
timeout_s=timeout_s,
)
return trigger_ci_rerun
__all__ = [
"CIRerunCallback",
"CIRerunResult",
"make_ci_rerun_callback",
"trigger_ci_rerun_via_empty_commit",
]