Files
homelab/hosts/mars/gitea-pr-comment-filter.py
darmanandClaude Opus 5 b516a800bf filter: make drops visible in the gateway log
Every drop so far has been silent. The script printed its reason to stderr
and exited 0 with "[SILENT]", but Hermes only logs stderr on the nonzero
path, as

  script ignored webhook path=... code=... stderr=...

so from outside, a deliberate drop, a crash, a timeout and a missing file all
looked identical: {"status":"ignored","reason":"script"} and nothing else.
Finding out which one it was meant re-running the payload through the script
by hand.

Drops now exit 3 with an empty stdout. Both still mean "ignored" to Hermes,
but the reason lands in the log. Exit 3 rather than 1 keeps a deliberate drop
distinguishable from an unhandled exception, which exits 1, so the code alone
says which happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 08:55:17 +02:00

125 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Hermes webhook filter for Gitea pull_request_comment deliveries.
Contract (gateway/platforms/webhook.py): the payload arrives on stdin as JSON.
STDOUT IS A PROTOCOL CHANNEL, not a log:
- exactly "[SILENT]" -> delivery ignored, no agent run, no tokens spent
- a JSON object -> REPLACES the payload used by the prompt template
- any other text -> delivery is ALLOWED THROUGH and the text is attached
as script_output
That last case is why every diagnostic here goes to stderr. A stray print()
would not drop an event, it would let one through.
Drops exit with DROP_EXIT_CODE and an empty stdout rather than printing
"[SILENT]" and exiting 0. Both mean "ignored" to Hermes, but only the nonzero
path is logged, as
script ignored webhook path=... code=3 stderr=...
which puts the reason in the gateway log. On the exit-0 path the reason goes
to stderr and is never surfaced anywhere, so a drop is indistinguishable from
a crash from a missing file -- which cost a long debugging detour once
already. code=3 is what separates a deliberate drop from a real crash: a
traceback exits 1.
Empty stdout, a nonzero exit, a missing script, or a timeout all count as
"ignored", so this script fails CLOSED: if it breaks, nothing reaches the
agent rather than everything. That is the right direction for a loop guard,
but it does mean a syntax error silently disables the whole integration --
run the test file next to this one after editing.
Two jobs:
1. Filter. Drop the deliveries that must never wake the agent -- above all
luna's own comments, which would otherwise loop forever: the prompt tells
her to reply on the PR, and her reply is itself a pull_request_comment.
2. Normalise. Guarantee changes.body.from always exists, so the prompt's
{changes.body.from} renders as empty rather than as an unfilled
placeholder on "created" events, where Gitea omits `changes` entirely.
"""
import json
import sys
# Comment authors whose comments must never wake the agent. luna is the agent
# herself (loop guard). Add "ci-bot" here if CI ever starts commenting on PRs
# and you do not want her reacting to build output.
IGNORED_AUTHORS = {"luna"}
# Exit code for a deliberate drop. Anything nonzero makes Hermes ignore the
# delivery AND log the reason; 3 distinguishes "a rule fired" from an
# unhandled exception, which exits 1.
DROP_EXIT_CODE = 3
# Gitea's HookIssueCommentAction values are created / edited / deleted.
# "deleted" is dropped: the payload still carries the comment body, so letting
# it through would have her act on a request that was explicitly withdrawn.
ALLOWED_ACTIONS = {"created", "edited"}
def ignore(reason: str) -> None:
"""Drop the delivery, loudly enough to find in the gateway log."""
print(f"gitea-pr-comment-filter: ignoring delivery: {reason}", file=sys.stderr)
raise SystemExit(DROP_EXIT_CODE)
def main() -> None:
try:
payload = json.loads(sys.stdin.read())
except (ValueError, OSError) as exc:
ignore(f"unparseable payload: {exc}")
if not isinstance(payload, dict):
ignore("payload is not a JSON object")
comment = payload.get("comment") or {}
issue = payload.get("issue") or {}
action = (payload.get("action") or "").strip().lower()
author = ((comment.get("user") or {}).get("login") or "").strip()
if action not in ALLOWED_ACTIONS:
ignore(f"action={action or '<missing>'}")
if author.lower() in IGNORED_AUTHORS:
ignore(f"author={author} is the agent itself (loop guard)")
# Belt and braces: the route already filters to pull_request_comment, but
# if that filter is ever loosened this keeps issue comments out. Only
# enforced when the key is actually present.
if "is_pull" in payload and not payload.get("is_pull"):
ignore("not a pull request comment (is_pull=false)")
body = (comment.get("body") or "").strip()
if not body:
ignore("empty comment body")
# Gitea omits `changes` on created events and populates changes.body.from
# with the pre-edit text on edits. Normalise it to a plain string so the
# prompt template always resolves, and drop no-op edits (a label or
# attachment change can fire "edited" without touching the body).
changes = payload.get("changes") or {}
previous = ((changes.get("body") or {}).get("from") or "") if isinstance(changes, dict) else ""
if action == "edited":
if previous.strip() == body:
ignore("edited but comment body is unchanged")
if not previous.strip():
print(
"gitea-pr-comment-filter: edited delivery carries no previous body; "
"passing through so the agent can reconcile from the PR thread",
file=sys.stderr,
)
payload["changes"] = {"body": {"from": previous}}
print(
"gitea-pr-comment-filter: allowing comment id=%s action=%s author=%s pr=%s"
% (comment.get("id"), action, author, issue.get("number")),
file=sys.stderr,
)
json.dump(payload, sys.stdout)
if __name__ == "__main__":
main()