Files
cleveragents-core/tools/controller/master/forgejo_writes.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

301 lines
9.9 KiB
Python

"""Forgejo write protocol for the master.
The master writes three categories of operator-visible state into
Forgejo:
1. Status comments (verbose what-happened summaries on the PR
timeline)
2. Labels (machine-readable + glanceable state markers)
3. Merge calls (Phase 1d-3c-3, separate module)
Per plan v9 simplified write ordering:
- Forgejo write FIRST with fingerprint-based idempotency
- DB commit SECOND
- Reconciliation tick syncs DB↔Forgejo if a crash happens between
This module ships the controller-side write logic. Forgejo HTTP is
injected via callbacks so tests don't need a live Forgejo instance.
Production wires the callbacks to the existing
``tools/_claim_runtime.get/post`` + ``_status_comments`` helpers.
Fingerprint pattern (carried forward from the existing
``_status_comments`` module): each operator-visible state has a
deterministic fingerprint hash of (workflow_id, event_kind, content).
The comment body includes a hidden HTML marker
``<!-- controller:fingerprint:abc123 -->``. Before posting, we list
existing comments + check for the marker; if found, skip the post.
This gives idempotency without needing the DB to be the source of
truth.
"""
from __future__ import annotations
import hashlib
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# Fingerprint marker prefix. Searchable + visible-but-quiet in
# Forgejo's UI (HTML comments don't render).
FINGERPRINT_MARKER_PREFIX = "<!-- controller:fingerprint:"
FINGERPRINT_MARKER_SUFFIX = " -->"
# Callback signatures. Each callback returns "what Forgejo said back"
# in a normalized shape.
#
# list_comments(owner, repo, pr_number) -> list of {"body": str, "id": int, ...}
# post_comment(owner, repo, pr_number, body) -> {"id": int, "body": str}
# get_labels(owner, repo, pr_number) -> list of {"name": str, "id": int, ...}
# add_label(owner, repo, pr_number, label_name) -> True on success
# remove_label(owner, repo, pr_number, label_name) -> True on success
ListCommentsCallback = Callable[[str, str, int], list[dict]]
PostCommentCallback = Callable[[str, str, int, str], dict]
GetLabelsCallback = Callable[[str, str, int], list[dict]]
AddLabelCallback = Callable[[str, str, int, str], bool]
RemoveLabelCallback = Callable[[str, str, int, str], bool]
@dataclass
class StatusCommentResult:
"""What ``post_status_comment`` returns.
Three outcomes:
- posted: a new comment was successfully posted.
- duplicate: a comment with the same fingerprint already exists;
this call was a no-op (idempotency working as intended).
- failed: the post-comment callback raised; caller decides
whether to retry on next tick.
"""
status: str # 'posted' | 'duplicate' | 'failed'
comment_id: int | None = None
fingerprint: str | None = None
error: str | None = None
@dataclass
class LabelAdjustResult:
"""Summary of one label adjust (set OR unset)."""
label: str
action: str # 'added' | 'removed' | 'no-op' | 'failed'
error: str | None = None
# ─── fingerprint helpers ──────────────────────────────────────────────
def compute_fingerprint(
*,
workflow_id: int,
event_kind: str,
content_key: str,
) -> str:
"""Build the deterministic fingerprint for a write.
The fingerprint is a short SHA256 prefix of
(workflow_id, event_kind, content_key) — enough to disambiguate
same-workflow / same-event-kind writes that differ only in content
(e.g., two STUCK transitions with different reasons).
"""
payload = f"{workflow_id}:{event_kind}:{content_key}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def build_marker(fingerprint: str) -> str:
"""Build the HTML marker the comment body must include."""
return f"{FINGERPRINT_MARKER_PREFIX}{fingerprint}{FINGERPRINT_MARKER_SUFFIX}"
def comment_has_fingerprint(comment_body: str, fingerprint: str) -> bool:
"""Check if a comment's body contains the marker for this fingerprint."""
return build_marker(fingerprint) in (comment_body or "")
# ─── status comment ───────────────────────────────────────────────────
def post_status_comment(
*,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
event_kind: str,
content_key: str,
body_text: str,
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
) -> StatusCommentResult:
"""Post a status comment with fingerprint-based idempotency.
Procedure:
1. Build the fingerprint from (workflow_id, event_kind, content_key).
2. Build the marker + check existing comments for it (via
``list_comments`` callback).
3. If a matching comment exists → return duplicate (no-op).
4. Else post a new comment with body = body_text + marker.
Failure of ``post_comment`` is returned as ``StatusCommentResult(
status='failed', error=...)`` — the caller decides whether to
retry on the next tick. ``list_comments`` failure falls through:
we conservatively post (risk: occasional duplicate; mitigated by
fingerprint match on next sweep).
"""
fingerprint = compute_fingerprint(
workflow_id=workflow_id,
event_kind=event_kind,
content_key=content_key,
)
marker = build_marker(fingerprint)
try:
existing = list_comments(owner, repo, pr_number) or []
except Exception as exc: # noqa: BLE001 — Forgejo flake; fall through to post
logger.warning(
"list_comments failed for #%d (%s); will post (risks duplicate): %s",
pr_number,
repo,
exc,
)
existing = []
for c in existing:
if comment_has_fingerprint(c.get("body") or "", fingerprint):
return StatusCommentResult(
status="duplicate",
comment_id=c.get("id"),
fingerprint=fingerprint,
)
body = f"{body_text}\n\n{marker}"
try:
posted = post_comment(owner, repo, pr_number, body)
except Exception as exc: # noqa: BLE001
return StatusCommentResult(
status="failed",
fingerprint=fingerprint,
error=str(exc),
)
return StatusCommentResult(
status="posted",
comment_id=(posted or {}).get("id"),
fingerprint=fingerprint,
)
# ─── labels ───────────────────────────────────────────────────────────
def adjust_labels(
*,
owner: str,
repo: str,
pr_number: int,
add: list[str] | None = None,
remove: list[str] | None = None,
get_labels: GetLabelsCallback,
add_label: AddLabelCallback,
remove_label: RemoveLabelCallback,
) -> list[LabelAdjustResult]:
"""Add + remove labels with no-op-on-already-set/missing.
Procedure:
1. List the PR's current labels (via ``get_labels``).
2. For each label in ``add``: if already present → no-op; else
add via ``add_label``.
3. For each label in ``remove``: if not present → no-op; else
remove via ``remove_label``.
Returns one LabelAdjustResult per label across both lists.
Failures are per-label (a failed add doesn't prevent a remove
of a different label).
"""
add = add or []
remove = remove or []
if not add and not remove:
return []
try:
current_labels = get_labels(owner, repo, pr_number) or []
except Exception as exc: # noqa: BLE001
# Defensive: if we can't list, mark every requested action
# as failed so the caller can retry on next tick.
err_msg = f"get_labels failed: {exc}"
return [
LabelAdjustResult(label=lbl, action="failed", error=err_msg)
for lbl in (add + remove)
]
current_names = {(c.get("name") or "") for c in current_labels}
results: list[LabelAdjustResult] = []
for lbl in add:
if lbl in current_names:
results.append(LabelAdjustResult(label=lbl, action="no-op"))
continue
try:
ok = add_label(owner, repo, pr_number, lbl)
results.append(
LabelAdjustResult(
label=lbl,
action="added" if ok else "failed",
error=None if ok else "add_label returned False",
)
)
except Exception as exc: # noqa: BLE001
results.append(
LabelAdjustResult(
label=lbl,
action="failed",
error=str(exc),
)
)
for lbl in remove:
if lbl not in current_names:
results.append(LabelAdjustResult(label=lbl, action="no-op"))
continue
try:
ok = remove_label(owner, repo, pr_number, lbl)
results.append(
LabelAdjustResult(
label=lbl,
action="removed" if ok else "failed",
error=None if ok else "remove_label returned False",
)
)
except Exception as exc: # noqa: BLE001
results.append(
LabelAdjustResult(
label=lbl,
action="failed",
error=str(exc),
)
)
return results
__all__ = [
"AddLabelCallback",
"FINGERPRINT_MARKER_PREFIX",
"FINGERPRINT_MARKER_SUFFIX",
"GetLabelsCallback",
"LabelAdjustResult",
"ListCommentsCallback",
"PostCommentCallback",
"RemoveLabelCallback",
"StatusCommentResult",
"adjust_labels",
"build_marker",
"comment_has_fingerprint",
"compute_fingerprint",
"post_status_comment",
]