3e3796d918
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.
280 lines
9.5 KiB
Python
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",
|
|
]
|