Add: ローカルGitea × Webhook連携で、AIとの開発をもっと楽にしよう!
All checks were successful
Deploy Docusaurus Site / deploy (push) Successful in 28s
All checks were successful
Deploy Docusaurus Site / deploy (push) Successful in 28s
This commit is contained in:
parent
df22d5ab0e
commit
4a0aa29eed
425
docs-tech/gitea-webhook-ai-review/index.md
Normal file
425
docs-tech/gitea-webhook-ai-review/index.md
Normal 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月時点の情報です。*
|
||||
@ -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が自動コードレビューしてくれる仕組み
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user