koide 4a0aa29eed
All checks were successful
Deploy Docusaurus Site / deploy (push) Successful in 28s
Add: ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう!
2026-02-26 04:41:34 +00:00

15 KiB
Raw Blame History

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 にコメント │
                                         └──────────────┘

流れ:

  1. Gitea で Issue が作成される
  2. システム Webhook が bridge.py に POST を送る
  3. bridge.py が HMAC 署名を検証し、openclaw agent コマンドを実行
  4. OpenClaw が code-review-loop スキルでリポジトリをレビュー&修正
  5. 結果が Gitea の Issue にコメントとして返ってくる

前提

この記事では以下が揃っている前提で進めます:

  • ローカル Gitea がセットアップ済み別ホストでもOK
  • OpenClawAI エージェントフレームワーク)がセットアップ済み
  • 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.jsonbind0.0.0.0 に変更しましょう。

3. Gitea の設定ファイルの場所

systemd で動かしている Gitea と手動起動の Gitea で、読み込む app.ini が違うことがあります。

# 実際にどの設定ファイルを読んでいるか確認
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 の起動確認

# ヘルスチェック
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月時点の情報です。