---
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. `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月時点の情報です。*
