0bc734c020
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>
288 lines
8.9 KiB
Python
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",
|
|
]
|