pilot: central webhook service (user-level Gitea webhook + AI-REVIEW gate)

- pilot/webhook_server.py: stdlib HTTP receiver. HMAC-verifies X-Gitea-Signature,
  gates on pull_request action + AI-REVIEW label, runs review_pr in a background
  thread (responds 202 immediately so Gitea's delivery timeout never fires).
  Accepts both GitHub-style (labeled/synchronize) and Gitea event-type-style
  (label_updated/synchronized) action names.
- pilot/ai_review.py: extract review_pr() core so both the CI run() and the
  webhook server share one review path. run() is now an env-driven wrapper.
- pilot/README-webhook.md: architecture, onboarding, one-time per-owner
  user-webhook setup, the Gitea 1.26.1 system-webhook bug, the SSRF
  ALLOWED_HOST_LIST change, K8s deploy + script-update recipe.
- README.md + design doc: note the webhook service as the preferred delivery
  path (partially reverses 'central webhook = non-goal', pilot only).

Gitea 1.26.1 system webhooks broken (POST /admin/hooks -> 201 but never
persists); user-level webhooks (one per repo-owner) are the working fallback.
Gitea SSRF allow-list blocks in-cluster webhook targets by default; required a
scoped [webhook] ALLOWED_HOST_LIST addition + gitea restart.

E2E verified 2026-08-17: pragent-bot reviewed gitea_admin/pragent PR #2 and
masi/portfolio PR #3 via the webhook service (glm-5.2:cloud).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marcos
2026-08-17 19:37:38 +00:00
parent 9379b52334
commit 789fb38bae
5 changed files with 430 additions and 18 deletions
+38 -14
View File
@@ -187,33 +187,57 @@ def _need(name: str) -> str:
return v
def run() -> int:
api = _need("GITEA_API")
repo = _need("GITEA_REPOSITORY")
index = _need("PR_INDEX")
token = _need("PRAGENT_BOT_TOKEN")
ollama_url = _need("OLLAMA_URL")
model = _need("OLLAMA_MODEL")
title = os.environ.get("PR_TITLE", "")
body = os.environ.get("PR_BODY", "")
sha = os.environ.get("PRAGENT_SHA", "")
max_tokens = int(os.environ.get("OLLAMA_MAX_TOKENS", "6000"))
max_chars = int(os.environ.get("DIFF_MAX_CHARS", "150000"))
def review_pr(
api: str,
repo: str,
index: str,
title: str,
body: str,
sha: str,
token: str,
ollama_url: str,
model: str,
max_tokens: int = 6000,
max_chars: int = 150000,
) -> bool:
"""Run one review and post it as a PR comment.
Returns True on success, False on failure (failure note is posted when
possible). Never raises — fail-open by design. Both the CI `run()` entry
point and the central webhook server call this.
"""
try:
diff, truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
if not diff.strip():
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
return 0
return True
user_prompt = build_user_prompt(title, body, diff)
findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
post_review(api, repo, index, token, format_review_body(findings, model, sha))
return True
except Exception as e: # fail-open
try:
post_review(api, repo, index, token, format_review_body(f"⚠️ AI review failed: {e}", model, sha))
except Exception as e2:
print(f"pragent: could not post failure note: {e2}", file=sys.stderr)
print(f"pragent: review failed: {e}", file=sys.stderr)
return False
def run() -> int:
review_pr(
api=_need("GITEA_API"),
repo=_need("GITEA_REPOSITORY"),
index=_need("PR_INDEX"),
title=os.environ.get("PR_TITLE", ""),
body=os.environ.get("PR_BODY", ""),
sha=os.environ.get("PRAGENT_SHA", ""),
token=_need("PRAGENT_BOT_TOKEN"),
ollama_url=_need("OLLAMA_URL"),
model=_need("OLLAMA_MODEL"),
max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")),
max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")),
)
return 0