Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
- 讽刺型 😈:毒舌吐槽,专治不服("这代码是用脚写的吗?")
- 绅士型 🌸:温柔建议,如沐春风("或许这里可以再优化一下呢~")
- 幽默型 🤪:搞笑点评,快乐改码("这段 if-else 比我的相亲经历还曲折!")
- 🤖 OpenCode Agent Review 集成
- 支持集成 OpenCode Serve API,通过 Agent 进行代码审查
- 当收到 PR/MR webhook 事件时,自动触发 OpenCode Agent Review
- 支持自定义 Agent 名称和 API 地址配置
- 可与内置 LLM Review 功能并行使用或独立使用

**效果图:**

Expand Down Expand Up @@ -71,6 +76,18 @@ DINGTALK_WEBHOOK_URL={YOUR_WDINGTALK_WEBHOOK_URL}

#Gitlab配置
GITLAB_ACCESS_TOKEN={YOUR_GITLAB_ACCESS_TOKEN}

#OpenCode Agent Review配置(可选)
#开启后webhook收到PR/MR事件时会发送请求到opencode serve的API进行review
OPENCODE_ENABLED=0 # 0关闭,1开启
OPENCODE_API_URL=http://localhost:4096 # OpenCode Serve API地址
OPENCODE_AGENT_NAME=code-reviewer # Agent名称
# 如果 OpenCode 服务器启用了认证,需要配置以下两项
# OPENCODE_SERVER_USERNAME=opencode
# OPENCODE_SERVER_PASSWORD=your-password

#LLM Review开关(设置为0则不通过内置LLM进行Code Review)
LLM_REVIEW_ENABLED=1 # 0关闭,1开启
```

**2. 启动服务**
Expand Down Expand Up @@ -162,6 +179,31 @@ streamlit run ui.py --server.port=5002 --server.address=0.0.0.0

企业微信和飞书推送配置类似,具体参见 [常见问题](doc/faq.md)

### 配置 OpenCode Agent Review

OpenCode Agent Review 是一个可选的代码审查功能,可以与内置的 LLM Review 功能并行使用或独立使用。

#### 1. 启用 OpenCode Agent Review

- 确保已部署 OpenCode Serve 服务(参考 OpenCode 官方文档),通常:`opencode web --hostname 0.0.0.0 --port 4096`
- 更新 .env 中的配置:
```bash
# OpenCode Agent Review配置
OPENCODE_ENABLED=1 # 0关闭,1开启
OPENCODE_API_URL=http://127.0.0.1:4096 # 替换为你的OpenCode Serve API地址
OPENCODE_AGENT_NAME=code-reviewer # 替换为你的Agent名称

# 如果 OpenCode 服务器启用了认证,需要配置以下两项
OPENCODE_SERVER_USERNAME=opencode
OPENCODE_SERVER_PASSWORD=your-password
```

#### 2. 功能说明

- 当 webhook 收到 GitHub/GitLab/Gitea 的 PR/MR 事件时,如果 `OPENCODE_ENABLED=1`,系统会自动调用 OpenCode API 创建 session 并发送 review 请求
- OpenCode Review 和内置 LLM Review 可以同时启用,两者互不影响
- 如果只需要使用 OpenCode Review,可以设置 `LLM_REVIEW_ENABLED=0` 来关闭内置 LLM Review

## 常见问题

**1.如何对整个代码库进行Review?**
Expand Down
152 changes: 112 additions & 40 deletions biz/api/routes/webhook.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Webhook 路由模块
"""

import json
import os
from urllib.parse import urlparse
Expand All @@ -13,15 +14,16 @@
handle_github_pull_request_event,
handle_github_push_event,
handle_gitea_pull_request_event,
handle_gitea_push_event
handle_gitea_push_event,
)
from biz.utils.log import logger
from biz.utils.queue import handle_queue
from biz.utils.opencode_client import is_opencode_enabled, send_opencode_review
from biz.utils.task_queue import handle_queue, handle_opencode_queue

webhook_bp = Blueprint('webhook', __name__)
webhook_bp = Blueprint("webhook", __name__)


@webhook_bp.route('/review/webhook', methods=['POST'])
@webhook_bp.route("/review/webhook", methods=["POST"])
def handle_webhook():
"""
处理 Webhook 请求的主路由
Expand All @@ -31,9 +33,10 @@ def handle_webhook():
data = request.get_json()
if not data:
return jsonify({"error": "Invalid JSON"}), 400

# 判断webhook来源
webhook_source_github = request.headers.get('X-GitHub-Event')
webhook_source_gitea = request.headers.get('X-Gitea-Event')
webhook_source_github = request.headers.get("X-GitHub-Event")
webhook_source_gitea = request.headers.get("X-Gitea-Event")

if webhook_source_gitea: # Gitea webhook优先处理
return handle_gitea_webhook(webhook_source_gitea, data)
Expand All @@ -42,39 +45,64 @@ def handle_webhook():
else: # GitLab webhook
return handle_gitlab_webhook(data)
else:
return jsonify({'message': 'Invalid data format'}), 400
return jsonify({"message": "Invalid data format"}), 400


def handle_github_webhook(event_type, data):
"""
处理 GitHub Webhook
"""
# 获取GitHub配置
github_token = os.getenv('GITHUB_ACCESS_TOKEN') or request.headers.get('X-GitHub-Token')
github_token = os.getenv("GITHUB_ACCESS_TOKEN") or request.headers.get(
"X-GitHub-Token"
)
if not github_token:
return jsonify({'message': 'Missing GitHub access token'}), 400
return jsonify({"message": "Missing GitHub access token"}), 400

github_url = os.getenv('GITHUB_URL') or 'https://github.com'
github_url = os.getenv("GITHUB_URL") or "https://github.com"
github_url_slug = slugify_url(github_url)

# 打印整个payload数据
logger.info(f'Received GitHub event: {event_type}')
logger.info(f'Payload: {json.dumps(data)}')
logger.info(f"Received GitHub event: {event_type}")
logger.info(f"Payload: {json.dumps(data)}")

if event_type == "pull_request":
# 异步触发 OpenCode Agent Review(如果启用)
if is_opencode_enabled():
pr_url = data.get("pull_request", {}).get("html_url")
if pr_url:
logger.info(
f"[OpenCode] GitHub PR webhook received, triggering OpenCode review for {pr_url}."
)
handle_opencode_queue(send_opencode_review, pr_url)

# 使用handle_queue进行异步处理
handle_queue(handle_github_pull_request_event, data, github_token, github_url, github_url_slug)
handle_queue(
handle_github_pull_request_event,
data,
github_token,
github_url,
github_url_slug,
)
# 立马返回响应
return jsonify(
{'message': f'GitHub request received(event_type={event_type}), will process asynchronously.'}), 200
{
"message": f"GitHub request received(event_type={event_type}), will process asynchronously."
}
), 200
elif event_type == "push":
# 使用handle_queue进行异步处理
handle_queue(handle_github_push_event, data, github_token, github_url, github_url_slug)
handle_queue(
handle_github_push_event, data, github_token, github_url, github_url_slug
)
# 立马返回响应
return jsonify(
{'message': f'GitHub request received(event_type={event_type}), will process asynchronously.'}), 200
{
"message": f"GitHub request received(event_type={event_type}), will process asynchronously."
}
), 200
else:
error_message = f'Only pull_request and push events are supported for GitHub webhook, but received: {event_type}.'
error_message = f"Only pull_request and push events are supported for GitHub webhook, but received: {event_type}."
logger.error(error_message)
return jsonify(error_message), 400

Expand All @@ -86,48 +114,67 @@ def handle_gitlab_webhook(data):
object_kind = data.get("object_kind")

# 优先从请求头获取,如果没有,则从环境变量获取,如果没有,则从推送事件中获取
gitlab_url = os.getenv('GITLAB_URL') or request.headers.get('X-Gitlab-Instance')
gitlab_url = os.getenv("GITLAB_URL") or request.headers.get("X-Gitlab-Instance")
if not gitlab_url:
repository = data.get('repository')
repository = data.get("repository")
if not repository:
return jsonify({'message': 'Missing GitLab URL'}), 400
return jsonify({"message": "Missing GitLab URL"}), 400
homepage = repository.get("homepage")
if not homepage:
return jsonify({'message': 'Missing GitLab URL'}), 400
return jsonify({"message": "Missing GitLab URL"}), 400
try:
parsed_url = urlparse(homepage)
gitlab_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
except Exception as e:
return jsonify({"error": f"Failed to parse homepage URL: {str(e)}"}), 400

# 优先从环境变量获取,如果没有,则从请求头获取
gitlab_token = os.getenv('GITLAB_ACCESS_TOKEN') or request.headers.get('X-Gitlab-Token')
gitlab_token = os.getenv("GITLAB_ACCESS_TOKEN") or request.headers.get(
"X-Gitlab-Token"
)
# 如果gitlab_token为空,返回错误
if not gitlab_token:
return jsonify({'message': 'Missing GitLab access token'}), 400
return jsonify({"message": "Missing GitLab access token"}), 400

gitlab_url_slug = slugify_url(gitlab_url)

# 打印整个payload数据,或根据需求进行处理
logger.info(f'Received event: {object_kind}')
logger.info(f'Payload: {json.dumps(data)}')
logger.info(f"Received event: {object_kind}")
logger.info(f"Payload: {json.dumps(data)}")

# 处理Merge Request Hook
if object_kind == "merge_request":
# 异步触发 OpenCode Agent Review(如果启用)
if is_opencode_enabled():
mr_url = data.get("object_attributes", {}).get("url")
if mr_url:
logger.info(
f"[OpenCode] GitLab MR webhook received, triggering OpenCode review for {mr_url}."
)
handle_opencode_queue(send_opencode_review, mr_url)

# 创建一个新进程进行异步处理
handle_queue(handle_merge_request_event, data, gitlab_token, gitlab_url, gitlab_url_slug)
handle_queue(
handle_merge_request_event, data, gitlab_token, gitlab_url, gitlab_url_slug
)
# 立马返回响应
return jsonify(
{'message': f'Request received(object_kind={object_kind}), will process asynchronously.'}), 200
{
"message": f"Request received(object_kind={object_kind}), will process asynchronously."
}
), 200
elif object_kind == "push":
# 创建一个新进程进行异步处理
# TODO check if PUSH_REVIEW_ENABLED is needed here
handle_queue(handle_push_event, data, gitlab_token, gitlab_url, gitlab_url_slug)
# 立马返回响应
return jsonify(
{'message': f'Request received(object_kind={object_kind}), will process asynchronously.'}), 200
{
"message": f"Request received(object_kind={object_kind}), will process asynchronously."
}
), 200
else:
error_message = f'Only merge_request and push events are supported (both Webhook and System Hook), but received: {object_kind}.'
error_message = f"Only merge_request and push events are supported (both Webhook and System Hook), but received: {object_kind}."
logger.error(error_message)
return jsonify(error_message), 400

Expand All @@ -136,25 +183,50 @@ def handle_gitea_webhook(event_type, data):
"""
处理 Gitea Webhook
"""
gitea_token = os.getenv('GITEA_ACCESS_TOKEN') or request.headers.get('X-Gitea-Token')
gitea_token = os.getenv("GITEA_ACCESS_TOKEN") or request.headers.get(
"X-Gitea-Token"
)
if not gitea_token:
return jsonify({'message': 'Missing Gitea access token'}), 400
return jsonify({"message": "Missing Gitea access token"}), 400

gitea_url = os.getenv('GITEA_URL') or 'https://gitea.com'
gitea_url = os.getenv("GITEA_URL") or "https://gitea.com"
gitea_url_slug = slugify_url(gitea_url)

logger.info(f'Received Gitea event: {event_type}')
logger.info(f'Payload: {json.dumps(data)}')
logger.info(f"Received Gitea event: {event_type}")
logger.info(f"Payload: {json.dumps(data)}")

if event_type == "pull_request":
handle_queue(handle_gitea_pull_request_event, data, gitea_token, gitea_url, gitea_url_slug)
# 异步触发 OpenCode Agent Review(如果启用)
if is_opencode_enabled():
pr_url = data.get("pull_request", {}).get("html_url") or data.get("pull_request", {}).get("url")
if pr_url:
logger.info(
f"[OpenCode] Gitea PR webhook received, triggering OpenCode review for {pr_url}."
)
handle_opencode_queue(send_opencode_review, pr_url)

handle_queue(
handle_gitea_pull_request_event,
data,
gitea_token,
gitea_url,
gitea_url_slug,
)
return jsonify(
{'message': f'Gitea request received(event_type={event_type}), will process asynchronously.'}), 200
{
"message": f"Gitea request received(event_type={event_type}), will process asynchronously."
}
), 200
elif event_type == "push":
handle_queue(handle_gitea_push_event, data, gitea_token, gitea_url, gitea_url_slug)
handle_queue(
handle_gitea_push_event, data, gitea_token, gitea_url, gitea_url_slug
)
return jsonify(
{'message': f'Gitea request received(event_type={event_type}), will process asynchronously.'}), 200
{
"message": f"Gitea request received(event_type={event_type}), will process asynchronously."
}
), 200
else:
error_message = f'Only pull_request and push events are supported for Gitea webhook, but received: {event_type}.'
error_message = f"Only pull_request and push events are supported for Gitea webhook, but received: {event_type}."
logger.error(error_message)
return jsonify(error_message), 400
return jsonify(error_message), 400
24 changes: 24 additions & 0 deletions biz/queue/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@
from biz.utils.log import logger


def is_llm_review_enabled() -> bool:
return os.environ.get('LLM_REVIEW_ENABLED', '1') == '1'


def handle_push_event(webhook_data: dict, gitlab_token: str, gitlab_url: str, gitlab_url_slug: str):
if not is_llm_review_enabled():
logger.info(
'[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for push event.'
)
return
push_review_enabled = os.environ.get('PUSH_REVIEW_ENABLED', '0') == '1'
try:
handler = PushHandler(webhook_data, gitlab_token, gitlab_url)
Expand Down Expand Up @@ -77,6 +85,9 @@ def handle_merge_request_event(webhook_data: dict, gitlab_token: str, gitlab_url
:param gitlab_url_slug:
:return:
'''
if not is_llm_review_enabled():
logger.info('[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for merge request event.')
return
merge_review_only_protected_branches = os.environ.get('MERGE_REVIEW_ONLY_PROTECTED_BRANCHES_ENABLED', '0') == '1'
try:
# 解析Webhook数据
Expand Down Expand Up @@ -165,7 +176,11 @@ def handle_merge_request_event(webhook_data: dict, gitlab_token: str, gitlab_url
notifier.send_notification(content=error_message)
logger.error('出现未知错误: %s', error_message)


def handle_github_push_event(webhook_data: dict, github_token: str, github_url: str, github_url_slug: str):
if not is_llm_review_enabled():
logger.info('[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for GitHub push event.')
return
push_review_enabled = os.environ.get('PUSH_REVIEW_ENABLED', '0') == '1'
try:
handler = GithubPushHandler(webhook_data, github_token, github_url)
Expand Down Expand Up @@ -227,6 +242,9 @@ def handle_github_pull_request_event(webhook_data: dict, github_token: str, gith
:param github_url_slug:
:return:
'''
if not is_llm_review_enabled():
logger.info('[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for GitHub pull request event.')
return
merge_review_only_protected_branches = os.environ.get('MERGE_REVIEW_ONLY_PROTECTED_BRANCHES_ENABLED', '0') == '1'
try:
# 解析Webhook数据
Expand Down Expand Up @@ -306,6 +324,9 @@ def handle_github_pull_request_event(webhook_data: dict, github_token: str, gith


def handle_gitea_push_event(webhook_data: dict, gitea_token: str, gitea_url: str, gitea_url_slug: str):
if not is_llm_review_enabled():
logger.info('[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for Gitea push event.')
return
push_review_enabled = os.environ.get('PUSH_REVIEW_ENABLED', '0') == '1'
try:
handler = GiteaPushHandler(webhook_data, gitea_token, gitea_url)
Expand Down Expand Up @@ -360,6 +381,9 @@ def handle_gitea_push_event(webhook_data: dict, gitea_token: str, gitea_url: str


def handle_gitea_pull_request_event(webhook_data: dict, gitea_token: str, gitea_url: str, gitea_url_slug: str):
if not is_llm_review_enabled():
logger.info('[LLM Review] LLM_REVIEW_ENABLED=0, skipping LLM review for Gitea pull request event.')
return
merge_review_only_protected_branches = os.environ.get('MERGE_REVIEW_ONLY_PROTECTED_BRANCHES_ENABLED', '0') == '1'
try:
handler = GiteaPullRequestHandler(webhook_data, gitea_token, gitea_url)
Expand Down
Loading