14 KiB
sidebar_position, title, description, hide_table_of_contents, displayed_sidebar
| sidebar_position | title | description | hide_table_of_contents | displayed_sidebar |
|---|---|---|---|---|
| 2 | ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう! | GiteaのWebhookとPythonブリッジを使って、Issue作成だけでAIが自動コードレビュー&修正してくれる仕組みを作る | false | null |
ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう!
はじめに
「Issue を立てるだけで、AI が自動でコードレビューして修正まで出してくれたらなぁ…」
そう思ったことはありませんか? 実はローカルの Gitea と Webhook、そして AI エージェントを繋げるだけで、わりと簡単にそんな世界が実現できます。
この記事では、Gitea で Issue が作成されたら自動で AI コードレビューが走る仕組みを、Python の標準ライブラリだけで作った話を紹介します。
全体像
構成はシンプルです。
┌─────────┐ Webhook (HTTP POST) ┌──────────────┐
│ Gitea │ ──────────────────────────→ │ bridge.py │
│ (別ホスト) │ │ (Python HTTP) │
└─────────┘ └──────┬───────┘
│
openclaw agent コマンド
│
┌──────▼───────┐
│ OpenClaw │
│ (AI Agent) │
└──────┬───────┘
│
code-review-loop スキル
│
┌──────▼───────┐
│ レビュー結果を │
│ Issue にコメント │
└──────────────┘
流れ:
- Gitea で Issue が作成される
- システム Webhook が
bridge.pyに POST を送る bridge.pyが HMAC 署名を検証し、openclaw agentコマンドを実行- OpenClaw が
code-review-loopスキルでリポジトリをレビュー&修正 - 結果が Gitea の Issue にコメントとして返ってくる
前提
この記事では以下が揃っている前提で進めます:
- ローカル Gitea がセットアップ済み(別ホストでもOK)
- OpenClaw(AI エージェントフレームワーク)がセットアップ済み
- bridge.py を動かすホストに Python 3 がある
:::tip
OpenClaw は AI エージェントを CLI やサービスとして動かせるフレームワークです。openclaw agent --message "..." で 1 回限りのタスクを実行できます。
:::
bridge.py の実装
ポイントは Python 標準ライブラリのみ で作っていること。pip install 不要です。
#!/usr/bin/env python3
"""Gitea Webhook Bridge - forwards new Issues to OpenClaw for auto code review."""
import hashlib
import hmac
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("bridge")
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
mac = hmac.new(secret.encode(), body, hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), signature)
def run_agent(task: str, label: str):
"""Run openclaw agent with a message instructing it to spawn a sub-agent."""
import time
session_id = f"gitea-webhook-{label}-{int(time.time())}"
message = (
f"以下のタスクを sessions_spawn (mode=run) で実行してください。"
f"自分では処理せず、必ず sessions_spawn tool を使って"
f"サブエージェントに委譲してください。\n\n"
f"label: {label}\n\n"
f"task:\n{task}"
)
cmd = [
"openclaw", "agent",
"--session-id", session_id,
"--message", message,
"--timeout", "120",
]
log.info("Running openclaw agent for label=%s", label)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=180,
)
if result.returncode == 0:
log.info("Agent completed for %s: %s", label, result.stdout[:200])
else:
log.error(
"Agent failed for %s: rc=%d stderr=%s",
label, result.returncode, result.stderr[:200],
)
except subprocess.TimeoutExpired:
log.error("Agent timed out for %s", label)
except Exception as e:
log.error("Agent error for %s: %s", label, e)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhook/gitea":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
# Signature verification
config = load_config()
secret_env = config.get("webhook_secret_env", "GITEA_WEBHOOK_SECRET")
secret = os.environ.get(secret_env, "")
if secret:
sig = self.headers.get("X-Gitea-Signature", "")
if not verify_signature(secret, body, sig):
log.warning("Signature verification failed")
self.send_response(403)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
# Only process issue events
event = self.headers.get("X-Gitea-Event", "")
if event != "issues":
log.info("Ignoring event: %s", event)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK (ignored event)")
return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
return
action = payload.get("action", "")
if action != "opened":
log.info("Ignoring action: %s", action)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK (ignored action)")
return
# Extract info
issue = payload.get("issue", {})
repo = payload.get("repository", {})
repo_full_name = repo.get("full_name", "")
clone_url = repo.get("clone_url", "")
number = issue.get("number", 0)
title = issue.get("title", "")
body_text = issue.get("body", "")
html_url = issue.get("html_url", "")
# Check ignore list
ignore_repos = config.get("ignore_repos", [])
if repo_full_name in ignore_repos:
log.info("Ignoring repo: %s", repo_full_name)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK (ignored repo)")
return
# Build task
task = (
f"Gitea Issue #{number} on {repo_full_name}: {title}\n\n"
f"{body_text}\n\n"
f"リポジトリ: {clone_url}\n"
f"Issue URL: {html_url}\n\n"
f"code-review-loop スキルに従ってレビュー&修正してください。"
)
label = f"gitea-review-{repo_full_name.replace('/', '-')}-{number}"
log.info("Spawning agent for %s#%d: %s", repo_full_name, number, title)
# Run in background thread
threading.Thread(
target=run_agent,
args=(task, label),
daemon=True,
).start()
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
return
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
def main():
config = load_config()
bind = config.get("bind", "127.0.0.1")
port = config.get("port", 9876)
server = HTTPServer((bind, port), WebhookHandler)
log.info("Listening on %s:%d", bind, port)
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("Shutting down")
server.server_close()
if __name__ == "__main__":
main()
設定ファイル
{
"bind": "0.0.0.0",
"port": 9876,
"webhook_secret_env": "GITEA_WEBHOOK_SECRET",
"ignore_repos": []
}
環境変数
GITEA_WEBHOOK_SECRET=ここにWebhookシークレットを設定
コードのポイント
- HMAC-SHA256 署名検証: Gitea が送ってくる
X-Gitea-Signatureヘッダーを検証。なりすまし防止 - Issue の
openedアクションのみ処理: 編集やクローズでは発火しない - バックグラウンドスレッド: Webhook のレスポンスを即座に返し、エージェント実行は非同期
ignore_repos: レビュー不要なリポジトリを除外できる/healthエンドポイント: 監視用のヘルスチェック
systemd サービス化
常駐させるために systemd ユニットを作ります。
[Unit]
Description=Gitea Webhook Bridge
After=network.target
[Service]
Type=simple
User=swallow
WorkingDirectory=/home/swallow/gitea-webhook-bridge
EnvironmentFile=/home/swallow/gitea-webhook-bridge/.env
ExecStart=/usr/bin/python3 /home/swallow/gitea-webhook-bridge/bridge.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-webhook-bridge
sudo systemctl status gitea-webhook-bridge
Gitea 側の設定
ALLOWED_HOST_LIST の設定
:::warning 重要 Gitea はデフォルトで プライベート IP への Webhook 送信をブロック します。ローカルネットワーク内で使う場合、この設定が必須です。 :::
Gitea の app.ini に以下を追加します:
[webhook]
ALLOWED_HOST_LIST = private
private を指定すると、RFC 1918 のプライベートアドレス(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)への送信が許可されます。
設定後は Gitea の再起動が必要です。
システム Webhook の追加
Gitea の サイト管理 → Webhook からシステム Webhook を追加します。
| 項目 | 設定値 |
|---|---|
| ターゲット URL | http://<bridge のホスト IP>:9876/webhook/gitea |
| Content Type | application/json |
| Secret | openssl rand -hex 20 で生成した値 |
| トリガー | Issues イベント |
システム Webhook にすると、全リポジトリに対して自動的に適用されます。個別に設定する手間がなくて楽です。
ハマりポイント 🤦
実際に構築してみて踏んだ地雷たちです。
1. ALLOWED_HOST_LIST を忘れて Webhook が飛ばない
Gitea の Webhook 配信テストで Delivery: ...is not allowed みたいなエラーが出たら、これが原因。app.ini に [webhook] セクションを追加し忘れていないか確認しましょう。
2. bind アドレスが 127.0.0.1 のまま
Gitea が別ホストにある場合、bridge.py が 127.0.0.1 で listen していると当然届きません。config.json の bind を 0.0.0.0 に変更しましょう。
3. openclaw session spawn は存在しない
OpenClaw の CLI コマンドを間違えがち。セッションを起動するには openclaw agent --message "..." を使います。
動作テスト
1. bridge.py の起動確認
# ヘルスチェック
curl http://localhost:9876/health
# → OK
2. Gitea から Webhook テスト
Gitea のサイト管理 → Webhook → 作成した Webhook → テスト配信 ボタンを押します。
bridge.py のログに以下のような出力が出ればOK:
2026-02-25 12:00:00 [INFO] Ignoring event: push
(テスト配信は push イベントなので無視されるのが正常)
3. 実際に Issue を作成
適当なリポジトリで Issue を作成してみましょう。bridge.py のログに:
2026-02-25 12:01:00 [INFO] Spawning agent for user/repo#1: テストIssue
2026-02-25 12:01:01 [INFO] Running openclaw agent for label=gitea-review-user-repo-1
と出て、しばらくすると Issue にレビュー結果のコメントが付きます 🎉
まとめ
Gitea + Webhook + Python ブリッジ + OpenClaw という組み合わせで、Issue を立てるだけで AI が自動レビューしてくれる仕組みが作れました。
構成要素はすべてセルフホストで、外部サービスに依存しないのがポイントです。Python の標準ライブラリだけで書いているので、依存管理も不要。
今後の拡張アイデア
- ラベルフィルタ: 特定のラベル(例:
ai-review)が付いた Issue だけ処理する - PR 自動作成: レビュー結果を Issue コメントだけでなく、修正 PR として作成する
- Push イベント対応: push 時にも自動レビューを走らせる
- Slack / Discord 通知: レビュー完了を通知する
セルフホスト環境で AI を活用した開発フローを作りたい人の参考になれば嬉しいです!
この記事は2026年2月時点の情報です。