Skip to content

Feature:添加代码行评审建议,通过MERGE_DETAIL_REVIEW_ENABLED开启 #113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
60 changes: 55 additions & 5 deletions biz/github/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,7 @@ def filter_changes(changes: list):
# 过滤 `new_path` 以支持的扩展名结尾的元素, 仅保留diff和new_path字段
filtered_changes = [
{
'diff': item.get('diff', ''),
'new_path': item['new_path'],
'additions': item.get('additions', 0),
'deletions': item.get('deletions', 0),
**item
}
for item in not_deleted_changes
if any(item.get('new_path', '').endswith(ext) for ext in supported_extensions)
Expand Down Expand Up @@ -105,9 +102,11 @@ def get_pull_request_changes(self) -> list:
changes = []
for file in files:
change = {
'old_path': file.get('filename'),
'old_path': file.get('previous_filename', ''),
'new_path': file.get('filename'),
'diff': file.get('patch', ''),
'status': file.get('status', ''),
'renamed_file': file.get('status') == 'renamed',
'additions': file.get('additions', 0),
'deletions': file.get('deletions', 0)
}
Expand Down Expand Up @@ -192,6 +191,57 @@ def target_branch_protected(self) -> bool:
logger.warn(f"Failed to get protected branches: {response.status_code}, {response.text}")
return False

def add_pull_request_comment(self, review):
"""向 GitHub Pull Request 的特定行添加评论"""
head_sha = self.webhook_data.get("pull_request", {}).get("head", {}).get("sha")
if not head_sha:
logger.error("无法添加评论,缺少 head_sha。")
return False

url = f"https://api.github.com/repos/{self.repo_full_name}/pulls/{self.pull_request_number}/comments"
headers = {
"Authorization": f"token {self.github_token}",
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json"
}

body = f"""**AI Review [{review.get('severity', 'N/A').upper()}]**: {review.get('category', 'General')}

**分析**: {review.get('analysis', 'N/A')}

**建议**:
```suggestion
{review.get('suggestion', 'N/A')}
```
"""

lines_info = review.get("lines", {})
file_path = review.get("file")

if not file_path:
logger.warning("跳过评论,审查缺少 'file' 路径。")
return False
if not lines_info or not lines_info.get('new'):
logger.warning("跳过评论,审查缺少 'lines' 信息。")
return False

payload = {
"body": body,
"commit_id": head_sha,
"path": file_path,
"line": lines_info["new"]
}

target_desc = f"file {file_path} line {lines_info['new']}"
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
logger.info(f"成功向 GitHub PR #{self.pull_request_number} ({target_desc}) 添加评论")
return True
except Exception as e:
logger.exception(f"添加 GitHub 评论 ({target_desc}) 时发生意外错误: {e}")
return False


class PushHandler:
def __init__(self, webhook_data: dict, github_token: str, github_url: str):
Expand Down
109 changes: 86 additions & 23 deletions biz/gitlab/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ def filter_changes(changes: list):
# 从环境变量中获取支持的文件扩展名
supported_extensions = os.getenv('SUPPORTED_EXTENSIONS', '.java,.py,.php').split(',')

# 过滤删除的文件
filter_deleted_files_changes = [change for change in changes if not change.get("deleted_file")]

# 过滤 `new_path` 以支持的扩展名结尾的元素, 仅保留diff和new_path字段
# 过滤 `new_path` 以支持的扩展名结尾的元素
filtered_changes = [
{
'diff': item.get('diff', ''),
'new_path': item['new_path'],
**item,
'additions': len(re.findall(r'^\+(?!\+\+)', item.get('diff', ''), re.MULTILINE)),
'deletions': len(re.findall(r'^-(?!--)', item.get('diff', ''), re.MULTILINE))
}
Expand Down Expand Up @@ -73,6 +73,21 @@ def parse_merge_request_event(self):
self.project_id = merge_request.get('target_project_id')
self.action = merge_request.get('action')

def get_merge_request(self) -> dict:
# 调用 GitLab API 获取 Merge Request 的 changes
url = urljoin(f"{self.gitlab_url}/",
f"api/v4/projects/{self.project_id}/merge_requests/{self.merge_request_iid}/changes")
headers = {
'Private-Token': self.gitlab_token
}
response = requests.get(url, headers=headers, verify=False)
# 检查请求是否成功
if response.status_code == 200:
return response.json()
else:
logger.warn(f"Failed to get changes from GitLab (URL: {url}): {response.status_code}, {response.text}")
return {}

def get_merge_request_changes(self) -> list:
# 检查是否为 Merge Request Hook 事件
if self.event_type != 'merge_request':
Expand All @@ -83,28 +98,16 @@ def get_merge_request_changes(self) -> list:
max_retries = 3 # 最大重试次数
retry_delay = 10 # 重试间隔时间(秒)
for attempt in range(max_retries):
# 调用 GitLab API 获取 Merge Request 的 changes
url = urljoin(f"{self.gitlab_url}/",
f"api/v4/projects/{self.project_id}/merge_requests/{self.merge_request_iid}/changes")
headers = {
'Private-Token': self.gitlab_token
}
response = requests.get(url, headers=headers, verify=False)
data = self.get_merge_request()
logger.debug(
f"Get changes response from GitLab (attempt {attempt + 1}): {response.status_code}, {response.text}, URL: {url}")

# 检查请求是否成功
if response.status_code == 200:
changes = response.json().get('changes', [])
if changes:
return changes
else:
logger.info(
f"Changes is empty, retrying in {retry_delay} seconds... (attempt {attempt + 1}/{max_retries}), URL: {url}")
time.sleep(retry_delay)
f"Get changes response from GitLab (attempt {attempt + 1}): {data}")
changes = data.get('changes', [])
if changes:
return changes
else:
logger.warn(f"Failed to get changes from GitLab (URL: {url}): {response.status_code}, {response.text}")
return []
logger.info(
f"Changes is empty, retrying in {retry_delay} seconds... (attempt {attempt + 1}/{max_retries})")
time.sleep(retry_delay)

logger.warning(f"Max retries ({max_retries}) reached. Changes is still empty.")
return [] # 达到最大重试次数后返回空列表
Expand Down Expand Up @@ -165,6 +168,66 @@ def target_branch_protected(self) -> bool:
logger.warn(f"Failed to get protected branches: {response.status_code}, {response.text}")
return False

def add_merge_request_comment(self, review, position_info):
"""向 GitLab Merge Request 的特定行添加评论"""
if not position_info or not position_info.get("head_sha") or not position_info.get(
"base_sha") or not position_info.get("start_sha"):
logger.error(
f"错误: 无法添加评论,缺少必要的位置信息 (head_sha/base_sha/start_sha)。得到: {position_info}")
return False

url = f"{self.gitlab_url}/api/v4/projects/{self.project_id}/merge_requests/{self.merge_request_iid}/discussions"
headers = {"PRIVATE-TOKEN": self.gitlab_token, "Content-Type": "application/json"}

body = f"""**AI Review [{review.get('severity', 'N/A').upper()}]**: {review.get('category', 'General')}

**分析**: {review.get('analysis', 'N/A')}

**建议**:
```suggestion
{review.get('suggestion', 'N/A')}
```
"""
position_data = {
"base_sha": position_info.get("base_sha"),
"start_sha": position_info.get("start_sha"),
"head_sha": position_info.get("head_sha"),
"position_type": "text",
}

lines_info = review.get("lines", {})
file_path = review.get("file")
old_file_path = review.get("old_path")

if not file_path:
logger.warning("跳过评论,审查缺少 'file' 路径。")
return False

if lines_info and lines_info.get("new") is not None:
position_data["new_path"] = file_path
position_data["new_line"] = lines_info["new"]
position_data["old_path"] = old_file_path if old_file_path else file_path
target_desc = f"file {file_path} line {lines_info['new']}"
elif lines_info and lines_info.get("old") is not None:
position_data["old_path"] = old_file_path if old_file_path else file_path
position_data["old_line"] = lines_info["old"]
position_data["new_path"] = file_path
target_desc = f"文件 {position_data['old_path']} 旧行号 {lines_info['old']}"
else:
logger.warning("跳过评论,审查缺少 'lines' 信息。")
return False

payload = {"body": body, "position": position_data}
logger.info(f"尝试向 {target_desc} 添加带位置的评论")
try:
response_obj = requests.post(url, headers=headers, json=payload)
response_obj.raise_for_status()
logger.info(f"成功向 GitLab MR {self.merge_request_iid} ({target_desc}) 添加评论")
return True
except Exception as e:
logger.exception(f"添加 GitLab 评论 ({target_desc}) 时发生意外错误: {e}")
return False


class PushHandler:
def __init__(self, webhook_data: dict, gitlab_token: str, gitlab_url: str):
Expand Down
38 changes: 34 additions & 4 deletions biz/queue/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from biz.utils.log import logger



def handle_push_event(webhook_data: dict, gitlab_token: str, gitlab_url: str, gitlab_url_slug: str):
push_review_enabled = os.environ.get('PUSH_REVIEW_ENABLED', '0') == '1'
try:
Expand Down Expand Up @@ -75,6 +74,7 @@ def handle_merge_request_event(webhook_data: dict, gitlab_token: str, gitlab_url
:return:
'''
merge_review_only_protected_branches = os.environ.get('MERGE_REVIEW_ONLY_PROTECTED_BRANCHES_ENABLED', '0') == '1'
merge_detail_review = os.environ.get('MERGE_DETAIL_REVIEW_ENABLED', '0') == '1'
try:
# 解析Webhook数据
handler = MergeRequestHandler(webhook_data, gitlab_token, gitlab_url)
Expand Down Expand Up @@ -111,8 +111,8 @@ def handle_merge_request_event(webhook_data: dict, gitlab_token: str, gitlab_url

# review 代码
commits_text = ';'.join(commit['title'] for commit in commits)
review_result = CodeReviewer().review_and_strip_code(str(changes), commits_text)

reviewer = CodeReviewer()
review_result = reviewer.review_and_strip_code(str(changes), commits_text)
# 将review结果提交到Gitlab的 notes
handler.add_merge_request_notes(f'Auto Review Result: \n{review_result}')

Expand All @@ -135,11 +135,26 @@ def handle_merge_request_event(webhook_data: dict, gitlab_token: str, gitlab_url
)
)

if merge_detail_review:
# 如果开启Merge请求详细Review,对每个变更的文件进行review
all_detail_review = reviewer.detail_review(changes)
merge_request = handler.get_merge_request()
success_count = 0
fail_count = 0
for review in all_detail_review:
if handler.add_merge_request_comment(review=review, position_info=merge_request.get("diff_refs")):
success_count += 1
else:
fail_count += 1
logger.info(f'Gitlab merge request detail review count: {len(all_detail_review)}, '
f'Success count: {success_count}, Fail count: {fail_count}')

except Exception as e:
error_message = f'AI Code Review 服务出现未知错误: {str(e)}\n{traceback.format_exc()}'
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):
push_review_enabled = os.environ.get('PUSH_REVIEW_ENABLED', '0') == '1'
try:
Expand Down Expand Up @@ -203,6 +218,7 @@ def handle_github_pull_request_event(webhook_data: dict, github_token: str, gith
:return:
'''
merge_review_only_protected_branches = os.environ.get('MERGE_REVIEW_ONLY_PROTECTED_BRANCHES_ENABLED', '0') == '1'
merge_detail_review = os.environ.get('MERGE_DETAIL_REVIEW_ENABLED', '0') == '1'
try:
# 解析Webhook数据
handler = GithubPullRequestHandler(webhook_data, github_token, github_url)
Expand Down Expand Up @@ -239,7 +255,8 @@ def handle_github_pull_request_event(webhook_data: dict, github_token: str, gith

# review 代码
commits_text = ';'.join(commit['title'] for commit in commits)
review_result = CodeReviewer().review_and_strip_code(str(changes), commits_text)
reviewer = CodeReviewer()
review_result = reviewer.review_and_strip_code(str(changes), commits_text)

# 将review结果提交到GitHub的 notes
handler.add_pull_request_notes(f'Auto Review Result: \n{review_result}')
Expand All @@ -262,6 +279,19 @@ def handle_github_pull_request_event(webhook_data: dict, github_token: str, gith
deletions=deletions,
))

if merge_detail_review:
# 如果开启Merge请求详细Review,对每个变更的文件进行review
all_detail_review = reviewer.detail_review(changes)
success_count = 0
fail_count = 0
for review in all_detail_review:
if handler.add_pull_request_comment(review=review):
success_count += 1
else:
fail_count += 1
logger.info(f'Github merge request detail review count: {len(all_detail_review)}, '
f'Success count: {success_count}, Fail count: {fail_count}')

except Exception as e:
error_message = f'服务出现未知错误: {str(e)}\n{traceback.format_exc()}'
notifier.send_notification(content=error_message)
Expand Down
72 changes: 72 additions & 0 deletions biz/utils/code_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,75 @@ def get_new_code(self):
if self.new_code is None:
self.parse_diff()
return self.new_code


def parse_single_file_diff(diff_text, file_path, old_file_path=None):
"""
解析单个文件的 unified diff 格式文本,提取变更信息。
返回包含该文件变更详情和上下文的字典。
"""
file_changes = {
"path": file_path,
"old_path": old_file_path,
"changes": [],
"context": {"old": [], "new": []},
"lines_changed": 0
}

old_line_num_current = 0
new_line_num_current = 0
hunk_context_lines = []

lines = diff_text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if line.startswith('--- ') or line.startswith('+++ '):
i += 1
continue
elif line.startswith('@@ '):
match = re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
if match:
old_line_num_start = int(match.group(1))
new_line_num_start = int(match.group(3))
old_line_num_current = old_line_num_start
new_line_num_current = new_line_num_start
if hunk_context_lines: # 将上一个 hunk 的上下文添加到 file_changes
file_changes["context"]["old"].extend(hunk_context_lines)
file_changes["context"]["new"].extend(hunk_context_lines)
hunk_context_lines = [] # 为新的 hunk 重置
else:
old_line_num_current = 0
new_line_num_current = 0
elif line.startswith('+'):
file_changes["changes"].append({
"type": "add",
"old_line": None,
"new_line": new_line_num_current,
"content": line[1:]
})
new_line_num_current += 1
elif line.startswith('-'):
file_changes["changes"].append({
"type": "delete",
"old_line": old_line_num_current,
"new_line": None,
"content": line[1:]
})
old_line_num_current += 1
elif line.startswith(' '): # Context line
hunk_context_lines.append(f"{old_line_num_current} -> {new_line_num_current}: {line[1:]}")
old_line_num_current += 1
new_line_num_current += 1
i += 1

if hunk_context_lines: # 添加最后一个 hunk 的上下文
file_changes["context"]["old"].extend(hunk_context_lines)
file_changes["context"]["new"].extend(hunk_context_lines)

limit = 20 # 限制上下文行数
file_changes["context"]["old"] = "\n".join(file_changes["context"]["old"][-limit:])
file_changes["context"]["new"] = "\n".join(file_changes["context"]["new"][-limit:])
file_changes["lines_changed"] = len([c for c in file_changes["changes"] if c['type'] in ['add', 'delete']])

return file_changes
Loading