Files
2026-07-13 11:59:58 +08:00

205 lines
9.0 KiB
Python

"""Translate GitHub webhook payloads into prompts for the agent.
Each supported event has its own template. The output is a single
human-readable string fed in as a ``role: user`` message to the agent.
Design notes:
* The prompt is descriptive ("a PR was opened on …"), not imperative
("review this PR"), so the agent's SOUL.md gets to define behavior.
The dispatcher appends one terse instruction at the end so a stock
``lead_agent`` SOUL still does something useful.
* The channel layer is **log-only on the outbound path** — the agent's
final message goes to ``gateway.log`` and is NOT posted to GitHub.
If the agent wants to reply on the PR/issue, it must call ``gh`` (or
the equivalent REST API) **during** the run. We do not promise the
agent that "your final message will be posted." That used to be true;
it isn't any more.
* We embed the comment body verbatim because that's the most useful
signal — the agent needs to see what the human actually typed. We
do not try to escape it; agents understand markdown.
* We never include the raw payload JSON; that's noise.
"""
from __future__ import annotations
from typing import Any
def _truncate(text: str | None, limit: int = 4000) -> str:
"""Trim long fields so a single bad payload doesn't blow the context window."""
if not text:
return ""
if len(text) <= limit:
return text
return text[: limit - 20] + "\n\n[…truncated…]"
def _pull_request_prompt(payload: dict[str, Any]) -> str:
pr = payload.get("pull_request") or {}
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
number = pr.get("number") or payload.get("number")
title = pr.get("title") or "(no title)"
author = (pr.get("user") or {}).get("login") or "(unknown)"
url = pr.get("html_url") or "(no url)"
action = payload.get("action") or "opened"
body = _truncate(pr.get("body"))
return (
f"A pull request was {action} on {repo}:\n\n"
f" #{number} {title}\n"
f" Author: {author}\n"
f" URL: {url}\n\n"
f"Description:\n{body or '(no description)'}\n\n"
f"Decide what action (if any) to take for this pull request and carry it out. "
f"Your final assistant message is for the run log only — it will NOT be "
f"posted to GitHub. If you want to reply on the PR, call `gh pr comment` "
f"(or `gh pr review`) yourself during the run."
)
def _render_parent_context(parent: dict[str, Any], kind: str) -> str:
"""Render the issue/PR the event hangs off as a header block.
``kind`` is ``"issue"`` or ``"pull request"`` — used in the heading
only. The webhook payload's ``issue``/``pull_request`` object already
carries the title, body, and author for the parent, so no extra API
call is needed for first-level context.
"""
title = parent.get("title") or "(no title)"
author = (parent.get("user") or {}).get("login") or "(unknown)"
url = parent.get("html_url") or "(no url)"
body = _truncate(parent.get("body"))
return f"Parent {kind}:\n Title: {title}\n Author: {author}\n URL: {url}\n\n Description:\n{body or '(no description)'}\n"
def _issue_comment_prompt(payload: dict[str, Any]) -> str:
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
issue = payload.get("issue") or {}
number = issue.get("number")
is_pr = "pull_request" in issue
target = "pull request" if is_pr else "issue"
parent_block = _render_parent_context(issue, target)
comment = payload.get("comment") or {}
author = (comment.get("user") or {}).get("login") or "(unknown)"
url = comment.get("html_url") or "(no url)"
body = _truncate(comment.get("body"))
return (
f"A new comment was posted on {target} #{number} in {repo}.\n\n"
f"{parent_block}\n"
f"New comment:\n"
f" Author: {author}\n"
f" URL: {url}\n\n"
f" Body:\n{body or '(empty comment)'}\n\n"
f"Decide what action (if any) to take in response to this comment, in the context "
f"of the parent {target} above. Your final assistant message is for the run log "
f"only — it will NOT be posted to GitHub. If you want to reply, call "
f"`gh issue comment {number} --repo {repo} --body-file -` yourself during the run."
)
def _pr_review_comment_prompt(payload: dict[str, Any]) -> str:
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
pr = payload.get("pull_request") or {}
number = pr.get("number")
parent_block = _render_parent_context(pr, "pull request")
comment = payload.get("comment") or {}
author = (comment.get("user") or {}).get("login") or "(unknown)"
path = comment.get("path") or "(unknown file)"
line = comment.get("line") or comment.get("original_line") or "?"
diff_hunk = _truncate(comment.get("diff_hunk"), limit=2000)
body = _truncate(comment.get("body"))
return (
f"A new review comment was posted on pull request #{number} in {repo}.\n\n"
f"{parent_block}\n"
f"Review comment:\n"
f" Author: {author}\n"
f" File: {path}:{line}\n\n"
f" Diff context:\n```\n{diff_hunk}\n```\n\n"
f" Body:\n{body or '(empty comment)'}\n\n"
f"Decide what action (if any) to take in response to this review comment, in the "
f"context of the parent pull request above. Your final assistant message is for the "
f"run log only — it will NOT be posted to GitHub. If you want to reply, call "
f"`gh pr comment {number} --repo {repo} --body-file -` yourself during the run."
)
def _pr_review_prompt(payload: dict[str, Any]) -> str:
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
pr = payload.get("pull_request") or {}
number = pr.get("number")
parent_block = _render_parent_context(pr, "pull request")
review = payload.get("review") or {}
state = review.get("state") or "(unknown state)"
author = (review.get("user") or {}).get("login") or "(unknown)"
body = _truncate(review.get("body"))
return (
f"A pull request review was submitted on #{number} in {repo}.\n\n"
f"{parent_block}\n"
f"Review:\n"
f" Reviewer: {author}\n"
f" State: {state}\n\n"
f" Body:\n{body or '(no review body)'}\n\n"
f"Decide what action (if any) to take in response to this review, in the context "
f"of the parent pull request above. Your final assistant message is for the run "
f"log only — it will NOT be posted to GitHub. If you want to reply (or push a fix), "
f"call `gh pr comment {number} --repo {repo} --body-file -` (and the usual "
f"`git clone` / `gh pr checkout` / `git push` flow for code changes) yourself "
f"during the run."
)
def _issues_prompt(payload: dict[str, Any]) -> str:
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
issue = payload.get("issue") or {}
number = issue.get("number")
title = issue.get("title") or "(no title)"
author = (issue.get("user") or {}).get("login") or "(unknown)"
url = issue.get("html_url") or "(no url)"
body = _truncate(issue.get("body"))
action = payload.get("action") or "opened"
return (
f"An issue was {action} on {repo}:\n\n"
f" #{number} {title}\n"
f" Author: {author}\n"
f" URL: {url}\n\n"
f"Description:\n{body or '(no description)'}\n\n"
f"Decide what action (if any) to take for this issue and carry it out. "
f"Your final assistant message is for the run log only — it will NOT be "
f"posted to GitHub. If you want to reply on the issue (or open a PR), call "
f"`gh issue comment {number} --repo {repo} --body-file -` / `gh pr create` "
f"yourself during the run."
)
def _ping_prompt(payload: dict[str, Any]) -> str:
# Ping events arrive when a webhook is first installed. We don't
# normally fire on them but include a template for completeness.
zen = payload.get("zen") or "(no zen)"
hook_id = (payload.get("hook") or {}).get("id")
return f"GitHub sent a ping event. zen={zen!r} hook_id={hook_id}\n\nNo action required."
_EVENT_BUILDERS: dict[str, Any] = {
"ping": _ping_prompt,
"pull_request": _pull_request_prompt,
"issue_comment": _issue_comment_prompt,
"pull_request_review_comment": _pr_review_comment_prompt,
"pull_request_review": _pr_review_prompt,
"issues": _issues_prompt,
}
def build_prompt(event: str, payload: dict[str, Any]) -> str:
"""Return the prompt string for a webhook delivery.
Unknown events get a generic stub so the dispatcher can still kick
off a run without crashing — useful when a new event type is
enabled before this module is updated.
"""
builder = _EVENT_BUILDERS.get(event)
if builder is None:
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
return f"GitHub event {event!r} fired on {repo}. action={payload.get('action')!r}"
return builder(payload)