From 4a0aa29eed85fe133576c0322841a83f3bcc48b4 Mon Sep 17 00:00:00 2001 From: koide Date: Thu, 26 Feb 2026 04:41:34 +0000 Subject: [PATCH] =?UTF-8?q?Add:=20=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=ABGite?= =?UTF-8?q?a=20=C3=97=20Webhook=E9=80=A3=E6=90=BA=E3=81=A7=E3=80=81AI?= =?UTF-8?q?=E3=81=A8=E3=81=AE=E9=96=8B=E7=99=BA=E3=82=92=E3=82=82=E3=81=A3?= =?UTF-8?q?=E3=81=A8=E6=A5=BD=E3=81=AB=E3=81=97=E3=82=88=E3=81=86=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-tech/gitea-webhook-ai-review/index.md | 425 +++++++++++++++++++++ docs-tech/index.md | 1 + src/pages/index.tsx | 6 + 3 files changed, 432 insertions(+) create mode 100644 docs-tech/gitea-webhook-ai-review/index.md diff --git a/docs-tech/gitea-webhook-ai-review/index.md b/docs-tech/gitea-webhook-ai-review/index.md new file mode 100644 index 0000000..228ac5b --- /dev/null +++ b/docs-tech/gitea-webhook-ai-review/index.md @@ -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://: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月時点の情報です。* diff --git a/docs-tech/index.md b/docs-tech/index.md index 56b38f4..daf6a40 100644 --- a/docs-tech/index.md +++ b/docs-tech/index.md @@ -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が自動コードレビューしてくれる仕組み diff --git a/src/pages/index.tsx b/src/pages/index.tsx index efd2b29..e4fe4d8 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -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",