Not executable under the toolset a webhook run actually got: Hermes defaults those to web_search/web_extract/vision_analyze/clarify, with no shell. Worth revisiting now that the routes grant `terminal` explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
126 lines
5.1 KiB
Python
126 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes webhook filter for Gitea pull request REVIEW deliveries.
|
|
|
|
Same stdout contract as gitea-pr-comment-filter.py next to this file -- read
|
|
that docstring first; the protocol, the fail-closed direction and the reason
|
|
drops exit 3 instead of printing "[SILENT]" are all identical and are not
|
|
repeated here.
|
|
|
|
What is different is the payload. A review is NOT an IssueCommentPayload: it
|
|
arrives as a PullRequestPayload with action "reviewed" and a `review` object
|
|
that Gitea defines (modules/structs/hook.go) as exactly two fields:
|
|
|
|
{"type": "<the HookEventType>", "content": "<the review's summary body>"}
|
|
|
|
There is no review id and no list of line comments, so this filter cannot see
|
|
what the review actually asks for -- the prompt has the agent fetch the
|
|
comments with `tea pulls review-comments`. `content` is routinely EMPTY (a
|
|
review whose substance is entirely in line comments has no summary body), so
|
|
an empty body is deliberately NOT a drop here, unlike in the comment filter.
|
|
|
|
review.type is the SUBSCRIPTION-namespace name, not the wire name, and the two
|
|
collide -- see the long comment in hermes-agent.nix. Both of the wire events
|
|
this route subscribes to map back to a review type here:
|
|
|
|
wire (X-GitHub-Event) review.type what it is
|
|
--------------------- ----------------------------- ------------------
|
|
pull_request_comment pull_request_review_comment review with a body
|
|
pull_request_rejected pull_request_review_rejected changes requested
|
|
|
|
Approvals (wire pull_request_approved) are not subscribed, so
|
|
pull_request_review_approved is not in ALLOWED_REVIEW_TYPES: an approval is
|
|
darman signing off, not asking for work. Add both to widen it.
|
|
"""
|
|
import json
|
|
import sys
|
|
|
|
# Reviewers whose reviews must never wake the agent. luna is the agent
|
|
# herself: she is told to reply with a PR comment rather than a review, so
|
|
# this is a backstop rather than the primary loop guard -- but she can post
|
|
# reviews via tea, and one self-review would otherwise recurse.
|
|
IGNORED_REVIEWERS = {"luna"}
|
|
|
|
# Exit code for a deliberate drop; see the comment filter's docstring.
|
|
DROP_EXIT_CODE = 3
|
|
|
|
# Reviews are the only thing this route should ever see. Every other
|
|
# PullRequestPayload action (opened, synchronized, label_updated, ...) means
|
|
# the hook was widened without widening the prompt.
|
|
ALLOWED_ACTIONS = {"reviewed"}
|
|
|
|
ALLOWED_REVIEW_TYPES = {
|
|
"pull_request_review_comment",
|
|
"pull_request_review_rejected",
|
|
}
|
|
|
|
|
|
def ignore(reason: str) -> None:
|
|
"""Drop the delivery, loudly enough to find in the gateway log."""
|
|
print(f"gitea-pr-review-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")
|
|
|
|
action = (payload.get("action") or "").strip().lower()
|
|
if action not in ALLOWED_ACTIONS:
|
|
ignore(f"action={action or '<missing>'}")
|
|
|
|
reviewer = ((payload.get("sender") or {}).get("login") or "").strip()
|
|
if reviewer.lower() in IGNORED_REVIEWERS:
|
|
ignore(f"reviewer={reviewer} is the agent itself (loop guard)")
|
|
|
|
review = payload.get("review")
|
|
if not isinstance(review, dict):
|
|
ignore("payload carries no review object")
|
|
|
|
review_type = (review.get("type") or "").strip().lower()
|
|
if review_type not in ALLOWED_REVIEW_TYPES:
|
|
ignore(f"review.type={review_type or '<missing>'}")
|
|
|
|
pull_request = payload.get("pull_request")
|
|
if not isinstance(pull_request, dict):
|
|
ignore("payload carries no pull_request object")
|
|
|
|
# Without a head branch there is nowhere to push, and the prompt would
|
|
# render an unfilled {pull_request.head.ref} placeholder.
|
|
head_ref = ((pull_request.get("head") or {}).get("ref") or "").strip()
|
|
if not head_ref:
|
|
ignore("pull_request.head.ref is missing")
|
|
|
|
# A review on a merged or closed PR is history, not a request. Gitea marks
|
|
# merged PRs closed too, so the state check covers both.
|
|
if (pull_request.get("state") or "").strip().lower() != "open":
|
|
ignore(f"pull request is {pull_request.get('state') or '<unknown>'}, not open")
|
|
|
|
number = payload.get("number")
|
|
repo = ((payload.get("repository") or {}).get("full_name") or "").strip()
|
|
if not number or not repo:
|
|
ignore(f"incomplete payload: number={number!r} repository.full_name={repo!r}")
|
|
|
|
# Normalise the two review fields to plain strings so the prompt template
|
|
# always resolves. Gitea omits neither in practice, but `content` being
|
|
# null rather than "" would render as the literal string "None".
|
|
payload["review"] = {
|
|
"type": review.get("type") or "",
|
|
"content": review.get("content") or "",
|
|
}
|
|
|
|
print(
|
|
"gitea-pr-review-filter: allowing review type=%s reviewer=%s pr=%s head=%s"
|
|
% (review_type, reviewer, number, head_ref),
|
|
file=sys.stderr,
|
|
)
|
|
json.dump(payload, sys.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|