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>
604 lines
22 KiB
Python
604 lines
22 KiB
Python
"""Deterministic applier for PR-compliance-checklist gaps.
|
|
|
|
What this is
|
|
------------
|
|
|
|
The implementer's compliance scan (:mod:`_implementer_compliance`)
|
|
checks four items mechanically:
|
|
|
|
- ``worktree_clean`` — sanity check, not fixable
|
|
- ``changelog_unreleased_nonempty`` — needs an entry under [Unreleased]
|
|
- ``contributors_has_author`` — needs the author email in CONTRIBUTORS.md
|
|
- ``commit_has_issues_closed`` — needs ``ISSUES CLOSED: #N`` in HEAD commit
|
|
|
|
Three of those (``contributors_has_author``, ``commit_has_issues_closed``,
|
|
``changelog_unreleased_nonempty``) can be applied without LLM reasoning
|
|
when the dispatcher has the source data:
|
|
|
|
- CONTRIBUTORS line: ``- {GIT_USER_NAME} <{GIT_USER_EMAIL}>``
|
|
- ISSUES CLOSED footer: derived from the prefetched ``linked_issues``
|
|
- CHANGELOG bullet: stubbed from the PR title (LLM-quality would be
|
|
marginally better here, but a PR-title stub is fine for most cases)
|
|
|
|
This module exposes :func:`apply_compliance_fixes` which takes a
|
|
worktree path + the prefetched data + the gap dict and applies the
|
|
fixes. It does NOT push — pushing is the caller's responsibility
|
|
(``dispatch_implementer._auto_fix_and_push``) because push failure
|
|
recovery needs cycle-level context.
|
|
|
|
The fixes are deterministic Python (no LLM). Every operation is
|
|
idempotent: calling :func:`apply_compliance_fixes` twice on a
|
|
worktree that's already had a fix applied is a no-op for that gap.
|
|
|
|
Why this exists
|
|
---------------
|
|
|
|
Live test 2026-05-13 (PR #28 cycle 1) showed the worker uses ~12-14
|
|
min of LLM time + 4096 server time on what's effectively five bash
|
|
commands. The same operations run deterministically in <5 seconds
|
|
with no LLM cost. ``auto-agents.md`` explicitly endorses this
|
|
pattern: "deterministic Python owns orchestration; the LLM only
|
|
handles the actual creative work."
|
|
|
|
The classes covered here are *not* creative work. Writing a real
|
|
code fix for a failing test IS creative work and stays on the LLM.
|
|
|
|
Failure handling
|
|
----------------
|
|
|
|
Each fix function returns ``(applied: bool, error: str | None)``.
|
|
:func:`apply_compliance_fixes` collects the results and propagates
|
|
them to the caller. On any error, the caller (the dispatcher's
|
|
auto-fix path) should fall through to spawning the LLM worker
|
|
rather than half-fixing the PR.
|
|
|
|
Audit trail
|
|
-----------
|
|
|
|
Each applied fix produces a structured entry in the returned
|
|
``ApplyReport`` (gap name, command sequence, before/after marker).
|
|
The dispatcher writes this into the cycle archive so an operator
|
|
inspecting why a commit appeared can trace it back to the
|
|
deterministic applier rather than wondering which LLM session
|
|
produced it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_logger = logging.getLogger("implementer_compliance_apply")
|
|
|
|
|
|
@dataclass
|
|
class ApplyResult:
|
|
"""Per-gap outcome of an apply attempt.
|
|
|
|
``applied``: True when the fix wrote something (or was already
|
|
satisfied — idempotent). False when an error prevented the
|
|
apply.
|
|
|
|
``error``: Human-readable error string. None on success.
|
|
|
|
``operations``: Ordered list of (command, output) pairs the
|
|
apply ran. Used by the cycle archive for audit-trail.
|
|
"""
|
|
|
|
gap_name: str
|
|
applied: bool
|
|
error: str | None = None
|
|
operations: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class ApplyReport:
|
|
"""Aggregate outcome of all attempted gap fixes.
|
|
|
|
``per_gap``: ApplyResult for each gap the caller asked to fix.
|
|
``all_applied``: True iff every requested gap fix succeeded.
|
|
``any_committed``: True if the apply created a new git commit
|
|
(the caller needs to know whether to push). A pure CONTRIBUTORS
|
|
update needs a commit. An ISSUES CLOSED amend re-uses HEAD's
|
|
commit (no new SHA). The combined fix is a single new commit
|
|
on top of HEAD.
|
|
"""
|
|
|
|
per_gap: list[ApplyResult] = field(default_factory=list)
|
|
all_applied: bool = True
|
|
any_committed: bool = False
|
|
final_head_sha: str = ""
|
|
|
|
|
|
def apply_compliance_fixes(
|
|
worktree: Path,
|
|
gaps: dict[str, bool],
|
|
*,
|
|
git_user_name: str,
|
|
git_user_email: str,
|
|
pr_title: str,
|
|
pr_number: int,
|
|
linked_issue_numbers: list[int],
|
|
) -> ApplyReport:
|
|
"""Apply deterministic fixes for the False entries in ``gaps``.
|
|
|
|
``gaps`` follows the contract from
|
|
:func:`_implementer_compliance.check_compliance_gaps` — keys are
|
|
check names, values are bool (True = passing, False = needs fix).
|
|
|
|
``linked_issue_numbers`` is the list of issue numbers the PR
|
|
closes (sourced from the prefetched ``linked_issues``). When
|
|
multiple issues are linked we include all of them in the
|
|
``ISSUES CLOSED`` footer. When the list is empty AND the
|
|
``commit_has_issues_closed`` gap is open, that fix is SKIPPED
|
|
(no data to apply) and recorded as an error in the report so
|
|
the caller falls through to the LLM worker.
|
|
|
|
The fix order is:
|
|
1. Apply CONTRIBUTORS append (file edit, staged)
|
|
2. Apply CHANGELOG stub (file edit, staged)
|
|
3. Stage and commit those two edits together
|
|
4. Amend the HEAD commit message to add ISSUES CLOSED footer
|
|
(works whether step 3 created a new commit or not; amending
|
|
the worker's previous commit is safe because we're in a
|
|
per-PR worktree)
|
|
|
|
Ordering matters: amend MUST be last because it rewrites HEAD.
|
|
"""
|
|
report = ApplyReport()
|
|
# Stage edits first; commit happens once for the file edits;
|
|
# amend at the end.
|
|
staged_changes = False
|
|
|
|
if not gaps.get("contributors_has_author", True):
|
|
result = _apply_contributors(worktree, git_user_name, git_user_email)
|
|
report.per_gap.append(result)
|
|
if result.applied and not result.error:
|
|
staged_changes = True
|
|
else:
|
|
report.all_applied = False
|
|
|
|
if not gaps.get("changelog_unreleased_nonempty", True):
|
|
result = _apply_changelog(worktree, pr_title, pr_number)
|
|
report.per_gap.append(result)
|
|
if result.applied and not result.error:
|
|
staged_changes = True
|
|
else:
|
|
report.all_applied = False
|
|
|
|
# Commit staged file changes (if any) before potentially amending
|
|
# the message in the next step. Doing it in two phases keeps the
|
|
# commit-message amend cleanly separable from the content add.
|
|
if staged_changes:
|
|
commit_result = _commit_staged(
|
|
worktree,
|
|
git_user_name,
|
|
git_user_email,
|
|
pr_title,
|
|
pr_number,
|
|
linked_issue_numbers,
|
|
)
|
|
report.per_gap.append(commit_result)
|
|
if commit_result.applied and not commit_result.error:
|
|
report.any_committed = True
|
|
else:
|
|
report.all_applied = False
|
|
|
|
# ISSUES CLOSED amend: applies whether or not we just committed.
|
|
# If we did commit above, the message already includes the
|
|
# footer (from `_commit_staged`); the amend is then a no-op
|
|
# idempotent re-check. If no file changes were needed, this
|
|
# amends the existing HEAD commit.
|
|
if not gaps.get("commit_has_issues_closed", True):
|
|
if not linked_issue_numbers:
|
|
report.per_gap.append(
|
|
ApplyResult(
|
|
gap_name="commit_has_issues_closed",
|
|
applied=False,
|
|
error=(
|
|
"no linked_issue_numbers — cannot derive "
|
|
"ISSUES CLOSED footer deterministically; "
|
|
"falling through to LLM worker"
|
|
),
|
|
)
|
|
)
|
|
report.all_applied = False
|
|
elif not staged_changes:
|
|
# Amend the EXISTING HEAD commit message rather than
|
|
# creating a new commit. This matches what the worker
|
|
# does today via the git-commit-util sub-agent.
|
|
amend_result = _amend_commit_footer(
|
|
worktree,
|
|
git_user_name,
|
|
git_user_email,
|
|
linked_issue_numbers,
|
|
)
|
|
report.per_gap.append(amend_result)
|
|
if amend_result.applied and not amend_result.error:
|
|
report.any_committed = True
|
|
else:
|
|
report.all_applied = False
|
|
# else: the new commit from _commit_staged already carries
|
|
# the ISSUES CLOSED footer; nothing more to do.
|
|
|
|
report.final_head_sha = _rev_parse_head(worktree)
|
|
return report
|
|
|
|
|
|
# ─── Per-gap appliers ──────────────────────────────────────────────
|
|
|
|
|
|
def _apply_contributors(
|
|
worktree: Path,
|
|
git_user_name: str,
|
|
git_user_email: str,
|
|
) -> ApplyResult:
|
|
"""Append ``- {name} <{email}>`` to CONTRIBUTORS.md if absent.
|
|
|
|
Creates the file with a minimal header when it doesn't exist.
|
|
Idempotent — if the line is already present the function
|
|
returns ``applied=True`` without writing.
|
|
"""
|
|
result = ApplyResult(gap_name="contributors_has_author", applied=False)
|
|
contributors = worktree / "CONTRIBUTORS.md"
|
|
line = f"- {git_user_name} <{git_user_email}>"
|
|
try:
|
|
if contributors.exists():
|
|
text = contributors.read_text(encoding="utf-8")
|
|
if git_user_email.lower() in text.lower():
|
|
# already present — idempotent success
|
|
result.applied = True
|
|
result.operations.append(
|
|
("read CONTRIBUTORS.md", "email already present")
|
|
)
|
|
return result
|
|
if not text.endswith("\n"):
|
|
text += "\n"
|
|
text += line + "\n"
|
|
else:
|
|
text = (
|
|
"# Contributors\n\n"
|
|
"Thanks to everyone who has contributed to this project:\n\n"
|
|
f"{line}\n"
|
|
)
|
|
contributors.write_text(text, encoding="utf-8")
|
|
result.applied = True
|
|
result.operations.append((f"write CONTRIBUTORS.md (+1 line)", line))
|
|
except OSError as exc:
|
|
result.error = f"CONTRIBUTORS.md write failed: {exc}"
|
|
return result
|
|
|
|
|
|
def _apply_changelog(
|
|
worktree: Path,
|
|
pr_title: str,
|
|
pr_number: int,
|
|
) -> ApplyResult:
|
|
"""Insert a stub bullet under [Unreleased] in CHANGELOG.md.
|
|
|
|
The stub uses the PR title verbatim. If CHANGELOG.md exists but
|
|
has no [Unreleased] section, one is inserted at the top below
|
|
the first ``# Changelog`` header. If CHANGELOG.md does not
|
|
exist, a minimal one is created.
|
|
|
|
The bullet appears as ``- {pr_title} (#{pr_number})`` to give a
|
|
cross-reference back to the PR. LLM-quality CHANGELOG entries
|
|
might say more, but the PR-title stub is the dominant pattern
|
|
in this codebase's history and is correct for most cases.
|
|
|
|
Idempotent: if a bullet referencing ``#{pr_number}`` already
|
|
exists under [Unreleased], no write happens.
|
|
"""
|
|
result = ApplyResult(gap_name="changelog_unreleased_nonempty", applied=False)
|
|
changelog = worktree / "CHANGELOG.md"
|
|
bullet = f"- {pr_title.strip()} (#{pr_number})"
|
|
try:
|
|
if changelog.exists():
|
|
text = changelog.read_text(encoding="utf-8")
|
|
# Idempotent guard
|
|
if f"(#{pr_number})" in text:
|
|
result.applied = True
|
|
result.operations.append(
|
|
("read CHANGELOG.md", "bullet for this PR already present")
|
|
)
|
|
return result
|
|
else:
|
|
text = "# Changelog\n\n## [Unreleased]\n\n"
|
|
# Find the [Unreleased] header; insert bullet under it.
|
|
unreleased_re = re.compile(
|
|
r"^(##\s*\[Unreleased\][^\n]*\n)",
|
|
re.MULTILINE | re.IGNORECASE,
|
|
)
|
|
m = unreleased_re.search(text)
|
|
if m:
|
|
insert_at = m.end()
|
|
# Insert a blank line + bullet + blank line if the
|
|
# header is immediately followed by content. Otherwise
|
|
# just bullet + blank line.
|
|
tail = text[insert_at:]
|
|
if tail.startswith("\n"):
|
|
# there's a blank after the header; just inject
|
|
new_text = text[:insert_at] + bullet + "\n" + tail
|
|
else:
|
|
new_text = text[:insert_at] + "\n" + bullet + "\n" + tail
|
|
else:
|
|
# No [Unreleased] section — add one near the top.
|
|
header_re = re.compile(r"^(#\s+[^\n]+\n)", re.MULTILINE)
|
|
hm = header_re.search(text)
|
|
new_section = f"\n## [Unreleased]\n\n{bullet}\n"
|
|
if hm:
|
|
new_text = text[: hm.end()] + new_section + text[hm.end() :]
|
|
else:
|
|
# No top-level header either; prepend everything.
|
|
new_text = f"# Changelog\n{new_section}\n{text}"
|
|
changelog.write_text(new_text, encoding="utf-8")
|
|
result.applied = True
|
|
result.operations.append(("write CHANGELOG.md (+1 bullet)", bullet))
|
|
except OSError as exc:
|
|
result.error = f"CHANGELOG.md write failed: {exc}"
|
|
return result
|
|
|
|
|
|
# ─── Git operations ────────────────────────────────────────────────
|
|
|
|
|
|
def _commit_staged(
|
|
worktree: Path,
|
|
git_user_name: str,
|
|
git_user_email: str,
|
|
pr_title: str,
|
|
pr_number: int,
|
|
linked_issue_numbers: list[int],
|
|
) -> ApplyResult:
|
|
"""Stage all changes in the worktree and commit them with a
|
|
compliance-fix-shaped commit message.
|
|
|
|
The commit message embeds the ``ISSUES CLOSED:`` footer pre-
|
|
populated from ``linked_issue_numbers`` so the subsequent amend
|
|
step is a no-op when the new commit is the HEAD. If
|
|
``linked_issue_numbers`` is empty, the message has no footer —
|
|
the amend step will then detect the missing footer and either
|
|
add it (if data is available) or leave the gap.
|
|
"""
|
|
result = ApplyResult(gap_name="commit_staged_compliance_fixes", applied=False)
|
|
msg_lines = [
|
|
f"chore(compliance): apply deterministic compliance fixes for #{pr_number}",
|
|
"",
|
|
f"Auto-applied by the implementer dispatcher's compliance scanner.",
|
|
f"Source PR: #{pr_number} — {pr_title.strip()}",
|
|
]
|
|
if linked_issue_numbers:
|
|
joined = " ".join(f"#{n}" for n in linked_issue_numbers)
|
|
msg_lines.append("")
|
|
msg_lines.append(f"ISSUES CLOSED: {joined}")
|
|
msg = "\n".join(msg_lines) + "\n"
|
|
try:
|
|
env = _env_for_commit(git_user_name, git_user_email)
|
|
_run_git(worktree, ["add", "-A"], env, result, "stage all")
|
|
_run_git(worktree, ["commit", "-m", msg], env, result, "commit")
|
|
result.applied = True
|
|
except _GitError as exc:
|
|
result.error = f"commit_staged failed: {exc}"
|
|
return result
|
|
|
|
|
|
def _amend_commit_footer(
|
|
worktree: Path,
|
|
git_user_name: str,
|
|
git_user_email: str,
|
|
linked_issue_numbers: list[int],
|
|
) -> ApplyResult:
|
|
"""Amend HEAD to add an ``ISSUES CLOSED: #N #M`` footer.
|
|
|
|
Reads the current message, appends the footer if not present,
|
|
rewrites via ``git commit --amend``. Idempotent: if the footer
|
|
is already there with the same issue numbers, no rewrite.
|
|
"""
|
|
result = ApplyResult(gap_name="commit_has_issues_closed", applied=False)
|
|
if not linked_issue_numbers:
|
|
result.error = "no linked_issue_numbers — cannot amend"
|
|
return result
|
|
try:
|
|
env = _env_for_commit(git_user_name, git_user_email)
|
|
# Read current message
|
|
rc, out = _run_git_capture(
|
|
worktree,
|
|
["log", "-1", "--pretty=%B", "HEAD"],
|
|
env,
|
|
)
|
|
if rc != 0:
|
|
result.error = f"git log -1 returned {rc}"
|
|
return result
|
|
current_msg = out.rstrip("\n")
|
|
joined = " ".join(f"#{n}" for n in linked_issue_numbers)
|
|
footer = f"ISSUES CLOSED: {joined}"
|
|
if footer in current_msg:
|
|
# Already there — idempotent success
|
|
result.applied = True
|
|
result.operations.append(("read HEAD message", "footer already present"))
|
|
return result
|
|
new_msg = current_msg.rstrip() + "\n\n" + footer + "\n"
|
|
_run_git(
|
|
worktree,
|
|
["commit", "--amend", "-m", new_msg],
|
|
env,
|
|
result,
|
|
"amend HEAD with footer",
|
|
)
|
|
result.applied = True
|
|
except _GitError as exc:
|
|
result.error = f"amend failed: {exc}"
|
|
return result
|
|
|
|
|
|
def _rev_parse_head(worktree: Path) -> str:
|
|
"""Return HEAD's SHA in the worktree, or empty string on error."""
|
|
try:
|
|
proc = subprocess.run(
|
|
["git", "-C", str(worktree), "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
if proc.returncode == 0:
|
|
return proc.stdout.strip()
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
return ""
|
|
|
|
|
|
# ─── Internal helpers ──────────────────────────────────────────────
|
|
|
|
|
|
class _GitError(Exception):
|
|
"""Raised by ``_run_git`` when a subprocess fails."""
|
|
|
|
|
|
def _env_for_commit(name: str, email: str) -> dict[str, str]:
|
|
"""Build an env override that pins the commit identity AND
|
|
disables GPG signing (otherwise dev machines with global signing
|
|
enabled would prompt or fail).
|
|
"""
|
|
import os
|
|
|
|
env = {**os.environ}
|
|
env["GIT_AUTHOR_NAME"] = name
|
|
env["GIT_AUTHOR_EMAIL"] = email
|
|
env["GIT_COMMITTER_NAME"] = name
|
|
env["GIT_COMMITTER_EMAIL"] = email
|
|
return env
|
|
|
|
|
|
def _run_git(
|
|
worktree: Path,
|
|
args: list[str],
|
|
env: dict[str, str],
|
|
result: ApplyResult,
|
|
label: str,
|
|
) -> None:
|
|
"""Run a git subcommand; raise ``_GitError`` on non-zero exit.
|
|
|
|
Records the operation in ``result.operations`` for audit trail.
|
|
Also unconditionally adds ``-c commit.gpgsign=false`` to defend
|
|
against developer-machine global signing configs (the live test
|
|
fixture pattern).
|
|
"""
|
|
cmd = ["git", "-c", "commit.gpgsign=false", "-C", str(worktree), *args]
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
result.operations.append((" ".join(cmd[:8]), f"<failed: {exc}>"))
|
|
raise _GitError(f"{label}: {exc}") from exc
|
|
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
result.operations.append((" ".join(cmd[:8]) + " ...", out[:200]))
|
|
if proc.returncode != 0:
|
|
raise _GitError(f"{label}: rc={proc.returncode}: {out[:300]}")
|
|
|
|
|
|
def _run_git_capture(
|
|
worktree: Path,
|
|
args: list[str],
|
|
env: dict[str, str],
|
|
) -> tuple[int, str]:
|
|
"""Run a git subcommand and return ``(rc, stdout)``. Stderr
|
|
discarded — for these read-only commands (``log``, ``rev-parse``)
|
|
we only care about stdout."""
|
|
cmd = ["git", "-C", str(worktree), *args]
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return -1, ""
|
|
return proc.returncode, (proc.stdout or "")
|
|
|
|
|
|
# ─── Push helper ───────────────────────────────────────────────────
|
|
|
|
|
|
def push_branch(
|
|
worktree: Path,
|
|
branch: str,
|
|
*,
|
|
git_env: dict[str, str],
|
|
) -> ApplyResult:
|
|
"""Push the worktree's HEAD to ``origin/<branch>``.
|
|
|
|
``git_env`` MUST be a credential-bearing git environment — the
|
|
one ``_pr_clone_creds._git_env(cfg)`` builds, carrying the
|
|
``GIT_ASKPASS`` shim that supplies the Forgejo PAT. The
|
|
dispatcher's pre-cloned worktree's ``origin`` is a bare HTTPS URL
|
|
with NO embedded credentials (by design — see
|
|
``_pr_clone._clone_url``), so a push run under a plain env fails
|
|
with ``fatal: could not read Username`` (rc=128) and the auto-fix
|
|
silently falls through to the LLM worker. Handing in the askpass
|
|
env — the same env the pre-clone/fetch already run under — is the
|
|
only thing that lets the deterministic auto-fix push authenticate.
|
|
The author/committer identity is irrelevant here: ``git push``
|
|
creates no commits, so this env need not (and does not) carry the
|
|
``GIT_AUTHOR_*`` / ``GIT_COMMITTER_*`` vars ``_env_for_commit``
|
|
sets for the commit/amend steps.
|
|
|
|
Uses ``--force-with-lease`` so a remote that moved while we were
|
|
applying gets a "stale info" rejection rather than a silent
|
|
overwrite. Disables mirror semantics via ``-c
|
|
remote.origin.mirror=false`` (the worker's per-command
|
|
workaround, which is robust across worktree configs even after
|
|
A1's clone-time fix lands).
|
|
"""
|
|
result = ApplyResult(gap_name="push_to_origin", applied=False)
|
|
cmd = [
|
|
"git",
|
|
"-c",
|
|
"remote.origin.mirror=false",
|
|
"-C",
|
|
str(worktree),
|
|
"push",
|
|
"--force-with-lease",
|
|
"origin",
|
|
f"HEAD:refs/heads/{branch}",
|
|
]
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
env=git_env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
result.operations.append(("git push --force-with-lease ...", out[:300]))
|
|
if proc.returncode == 0:
|
|
result.applied = True
|
|
else:
|
|
result.error = f"push rc={proc.returncode}: {out[:300]}"
|
|
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
result.error = f"push subprocess failed: {exc}"
|
|
return result
|
|
|
|
|
|
__all__ = (
|
|
"ApplyResult",
|
|
"ApplyReport",
|
|
"apply_compliance_fixes",
|
|
"push_branch",
|
|
)
|