Files
cleveragents-core/tools/controller/master/forgejo_writes.py
T
drew 3e3796d918 feat(controller): Phase 1d-3c-2 — Forgejo write helpers (status + labels)
Idempotent status comment posting via fingerprint markers + per-label
no-op-aware adjust. Production wires the callbacks to existing
_status_comments + _claim_runtime helpers; tests use fakes.

tools/controller/master/forgejo_writes.py:

- compute_fingerprint(workflow_id, event_kind, content_key) →
  16-char SHA256 prefix. Deterministic; same inputs → same fingerprint.
  Different (workflow_id OR event_kind OR content_key) → different fp.

- build_marker(fp) → HTML comment "<!-- controller:fingerprint:abc -->".
  Searchable + invisible in Forgejo's rendered UI.

- comment_has_fingerprint(body, fp) → bool. Used by post_status_comment
  for the dedup check before posting.

- post_status_comment(): full idempotency protocol —
  1. compute fingerprint
  2. list_comments callback → check existing for marker
  3. if found → return duplicate (no post)
  4. else post via post_comment callback
  Failure modes:
  - list_comments raises → fall through to post (conservative;
    fingerprint match on next sweep catches the duplicate)
  - post_comment raises → return failed; caller retries

- adjust_labels(add, remove): one-shot get_labels + per-label
  add/remove. add-when-already-present → no-op; remove-when-absent
  → no-op. Per-label failure isolated. get_labels failure marks
  all requested actions failed (caller retries).

19 new tests:
- fingerprint helpers (5: deterministic, distinct inputs, marker
  format, body match, empty body no-match)
- post_status_comment (5: new post, duplicate skip, list-failure
  fall-through, post failure, distinct event_kinds get distinct fps)
- adjust_labels (9: empty no-op, add-when-absent, add no-op,
  remove-when-present, remove no-op, get failure, per-label
  isolation with mixed failures, returning-False failure)

Total: 329 controller tests; full auto_agents suite 2691 pass.

Phase 1d-3c-3 (MERGING handler) + Phase 1c-3 (real OpenCode + MCP
spawn) remain.
2026-05-18 13:53:09 -04:00

280 lines
9.5 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",
]