Add: ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう!
All checks were successful
Deploy Docusaurus Site / deploy (push) Successful in 28s

This commit is contained in:
koide 2026-02-26 04:41:34 +00:00
parent df22d5ab0e
commit 4a0aa29eed
3 changed files with 432 additions and 0 deletions

View File

@ -0,0 +1,425 @@
---
sidebar_position: 2
title: ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう
description: GiteaのWebhookとPythonブリッジを使って、Issue作成だけでAIが自動コードレビュー修正してくれる仕組みを作る
hide_table_of_contents: false
displayed_sidebar: 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 にコメント │
└──────────────┘
```
**流れ:**
1. Gitea で Issue が作成される
2. システム Webhook が `bridge.py` に POST を送る
3. `bridge.py` が HMAC 署名を検証し、`openclaw agent` コマンドを実行
4. OpenClaw が `code-review-loop` スキルでリポジトリをレビュー&修正
5. 結果が Gitea の Issue にコメントとして返ってくる
## 前提
この記事では以下が揃っている前提で進めます:
- **ローカル Gitea** がセットアップ済み別ホストでもOK
- **OpenClaw**AI エージェントフレームワーク)がセットアップ済み
- bridge.py を動かすホストに Python 3 がある
:::tip
OpenClaw は AI エージェントを CLI やサービスとして動かせるフレームワークです。`openclaw agent --message "..."` で 1 回限りのタスクを実行できます。
:::
## bridge.py の実装
ポイントは **Python 標準ライブラリのみ** で作っていること。`pip install` 不要です。
```python title="bridge.py"
#!/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()
```
### 設定ファイル
```json title="config.json"
{
"bind": "0.0.0.0",
"port": 9876,
"webhook_secret_env": "GITEA_WEBHOOK_SECRET",
"ignore_repos": []
}
```
### 環境変数
```bash title=".env"
GITEA_WEBHOOK_SECRET=ここにWebhookシークレットを設定
```
### コードのポイント
- **HMAC-SHA256 署名検証**: Gitea が送ってくる `X-Gitea-Signature` ヘッダーを検証。なりすまし防止
- **Issue の `opened` アクションのみ処理**: 編集やクローズでは発火しない
- **バックグラウンドスレッド**: Webhook のレスポンスを即座に返し、エージェント実行は非同期
- **`ignore_repos`**: レビュー不要なリポジトリを除外できる
- **`/health` エンドポイント**: 監視用のヘルスチェック
## systemd サービス化
常駐させるために systemd ユニットを作ります。
```ini title="/etc/systemd/system/gitea-webhook-bridge.service"
[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
```
```bash
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` に以下を追加します:
```ini title="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. Gitea の設定ファイルの場所
systemd で動かしている Gitea と手動起動の Gitea で、読み込む `app.ini` が違うことがあります。
```bash
# 実際にどの設定ファイルを読んでいるか確認
ps aux | grep gitea
```
起動コマンドの `--config` オプションや `GITEA_WORK_DIR` 環境変数をチェック。
### 4. Gitea が複数プロセス起動している
systemd 版と手動起動版が共存していて、Webhook の設定変更が反映されない…というケースがありました。`ps aux | grep gitea` で確認して、片方に統一しましょう。
### 5. `openclaw session spawn` は存在しない
OpenClaw の CLI コマンドを間違えがち。セッションを起動するには `openclaw agent --message "..."` を使います。
## 動作テスト
### 1. bridge.py の起動確認
```bash
# ヘルスチェック
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月時点の情報です。*

View File

@ -23,3 +23,4 @@ slug: /
- [ローカルサーバーでマイク・カメラを使う方法](/tech/browser-secure-context/)
- [SearXNGでローカル検索APIを構築する](/tech/searxng-local-search/)
- [ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう](/tech/gitea-webhook-ai-review/) - Issue作成だけでAIが自動コードレビューしてくれる仕組み

View File

@ -18,6 +18,12 @@ function HomepageHeader() {
function RecentTech() {
const posts = [
{
title: "ローカルGitea × Webhook連携でAI自動コードレビュー",
date: "2026-02-26",
tag: "Gitea",
url: "/tech/gitea-webhook-ai-review",
},
{
title: "DGX SparkにAnythingLLMでローカルLLMエージェント構築",
date: "2026-02-20",