用 GitLab MR Pipeline 接入 GLM-5 自动代码审查:不用自建 Web 服务
AIAI Summary (BLUF)
这篇教程手把手教你在 GitLab 中接入 AI 代码审查。通过配置 MR Pipeline、GitLab Runner 和 Python 脚本,让 GLM-5 自动读取代码差异并写回审查评论。适合想为团队引入自动化代码审查的开发者。
核心洞察
用 MR PipelineGitLab 的合并请求流水线,可以在创建或更新 MR 时自动运行专门的 CI/CD 任务。 做 AI Code Review,最大的好处是不用自己搭 Web 服务、不用管公网回调,所有日志都在 GitLab一个基于 Git 的 DevOps 平台,提供代码托管、CI/CD、项目管理等功能。 里,出了问题直接翻 job log 就行。这篇文章手把手从建 token 到写 CI 配置全部走了一遍,跟着做基本不会卡住。唯一要注意的是行内评论那部分,第一轮建议先关掉,跑通总评再说。
核心结论
用 MR Pipeline 做 AI Code Review 的核心链路是:开发者提交 MR → GitLab MR Pipeline 触发 → Runner 执行 Python 脚本 → 脚本读取 MR diff → 调用 GLM-5智谱AI新一代旗舰基座模型,面向Agentic Engineering打造,在Coding与Agent能力上取得开源SOTA表现。 → 通过 GitLab Discussions APIGitLab 提供的 API,用于在 Merge Request 中创建和管理讨论评论。 写回评论。
推荐新手采用「MR Pipeline + Python 脚本 + GitLab API + GLM-5」方案,无需公网回调地址、无需自建 Web 服务,所有日志都在 GitLab job log 中,排查更方便。
接入前需准备 4 样东西:一个 GitLab 项目、一个可用的 GitLab RunnerGitLab CI/CD 的执行器,负责运行流水线中定义的任务。、一个 GitLab 项目访问令牌(Scopes 只勾选
api,Role 用Developer)、一个智谱 GLM-5 的 API Key。第一轮测试建议将
REVIEW_INLINE设为false,先只跑通 AI 总评,暂不开启更容易出错的行内评论。关键配置变量包括
REVIEW_MODEL=glm-5、REVIEW_MAX_FILES=20、REVIEW_MAX_DIFF_CHARS=50000、REVIEW_MAX_COMMENTS=8;敏感变量GITLAB_API_TOKEN和ZHIPU_API_KEY应设为Masked and hidden且不勾选 Protect。
GitLab 中接入 AI Code Review 完整指南
本文目标是为一个示例项目接入自动化 AI Code Review,效果是:
- 开发者发起 Merge RequestGitLab 中的合并请求,开发者通过 MR 将代码从一个分支合并到另一个分支,并触发代码审查流程。
- GitLab 自动触发 MR Pipeline
- Pipeline 调用 GLM-5
- AI 自动把总评和可选的行内评论写回 MR
GitLab 官方说明,Merge request pipelines 可以在创建或更新 MR 时运行专门的 CI/CD 任务;CI/CD 任务由仓库根目录的 .gitlab-ci.yml 配置。
一、最终效果与示例命名
为了避免和你的真实项目混淆,本文统一使用这些示例名称:
- GitLab Group:
example_team - GitLab Project:
demo_ai_platform - GitLab 测试分支:
test/ai-code-review-smoke - GitLab Runner 名称:
ai-review-runner - GitLab 项目访问令牌名称:
ai-review-bot - 测试文件:
quicksort_demo.py
最终链路是:
开发者提交 MR → GitLab MR Pipeline 触发 → Runner 执行 Python 脚本 → 脚本读取 MR diff → 调用 GLM-5 → 通过 GitLab Discussions API 写回评论。GitLab 的 Merge requests API 和 Discussions API 都支持这种自动化集成。
二、开始前你需要准备什么
先准备 4 样东西:
- 一个 GitLab 项目,本文用
example_team/demo_ai_platform - 一个可用的 GitLab Runner
- 一个 GitLab 项目访问令牌
- 一个智谱
GLM-5的 API Key
GitLab 官方说明,项目访问令牌是项目级身份凭证,适合给自动化程序访问当前项目 API;GitLab Runner 负责执行 CI/CD job;而 GitLab CI/CD 变量会以环境变量的形式注入 job 运行环境。
三、推荐的整体方案
对新手来说,最稳的方案不是先做 Webhook 服务,而是先做:
MR Pipeline + Python 脚本 + GitLab API + GLM-5
原因很简单:
- 不需要公网回调地址
- 不需要自建 Web 服务
- 日志全部在 GitLab 里
- 出问题更容易排查
- 跑通后再升级成 webhook 方案也不难
GitLab 官方把 pipeline 作为 CI/CD 的基础机制,允许在创建 MR 等事件时自动运行。
四、第一步:创建 GitLab 项目访问令牌
进入项目:
Settings → Access tokens
按下面填写:
- Token name:
ai-review-bot - Description:
AI Code Review bot for Merge Request comments - Expiration date:先用 30 天或 90 天
- Role:
Developer - Scopes:只勾选
api
为什么这么选:
- 这个 bot 要读取 MR、读取 diff、写 MR discussion,所以需要 API 权限。
- 用
Developer通常够用,不建议一开始就给更高权限。 - 令牌值只会在创建后展示一次,应该立即保存。
建议把这个 token 记为:
GITLAB_API_TOKEN
五、第二步:在 GitLab 中配置 CI/CD VariablesGitLab 中定义的变量,会以环境变量形式注入到 CI/CD 任务运行环境中。
进入:
Settings → CI/CD → Variables
需要创建这些变量。
1. 敏感变量
创建:
GITLAB_API_TOKENZHIPU_API_KEY
建议设置为:
- Type:
Variable - Environment:
All (default) - Visibility:
Masked and hidden - Protect variable:不要勾选
- Expand variable reference:不要勾选
GitLab 官方说明,变量可以设置为 Masked 或 Masked and hidden;隐藏后变量仍可在流水线中使用,但不能再在 UI 中显示原值。官方也说明,Protected variable 只会在受保护分支或标签的流水线中可用。
2. 普通配置变量
创建:
REVIEW_MODEL=glm-5REVIEW_INLINE=falseREVIEW_MAX_FILES=20REVIEW_MAX_DIFF_CHARS=50000REVIEW_MAX_COMMENTS=8
第一轮测试把 REVIEW_INLINE 设成 false,这样先只测 AI 总评,不测更容易出错的行内评论。
如果你使用智谱默认通用端点,就不用创建 ZHIPU_BASE_URL。
如果你确实使用特殊编码端点,再补一个变量:
ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
智谱官方当前文档给出的对话补全接口是:
POST https://open.bigmodel.cn/api/paas/v4/chat/completions
并使用:
Authorization: Bearer <token>
认证。
六、第三步:准备 GitLab Runner
GitLab 官方建议:Runner 最好安装在与 GitLab 实例不同的机器上,这是出于安全和性能考虑。
但如果你只是测试或小规模使用,也可以在同一台 Ubuntu 22.04 机器上通过 Docker 运行 Runner。GitLab 官方支持把 Runner 作为 Docker 容器运行,并支持 Docker executor。
1. 在 GitLab 中创建 Project Runner
进入:
Settings → CI/CD → Runners → Create project runner
建议这样填写:
- Tags:留空
- Run untagged jobs:勾选
- Runner description:
ai-review-runner - Paused:不勾
- Protected:不勾
- Lock to current projects:勾选
- Maximum job timeout:留空
这样做的原因是:
- 你的 job 默认没有写
tags:,所以 Runner 需要允许执行 untagged job。 - MR 测试通常来自普通开发分支,
Protected容易导致任务跑不起来。 - 锁定到当前项目更符合最小权限原则。
2. 在 Linux 机器上用 Docker 启动 Runner
在 Ubuntu 22.04 主机上执行:
sudo mkdir -p /srv/gitlab-runner/config
sudo docker pull gitlab/gitlab-runner:latest
sudo docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
GitLab 官方提供了这种容器化运行 Runner 的方式。
3. 注册 Runner
在 GitLab 页面创建 Runner 后,会得到一个 glrt-... 开头的认证 token。
执行:
sudo docker exec -it gitlab-runner gitlab-runner register \
--non-interactive \
--url "http://gitlab.example.local" \
--token "<YOUR_RUNNER_TOKEN>" \
--executor "docker" \
--docker-image "python:3.11-slim" \
--description "ai-review-runner"
GitLab 官方文档说明,Runner 可以使用 register --executor "docker" 注册为 Docker executor。
4. 建议把并发限制为 1
如果 Runner 和 GitLab 在同一台主机上运行,建议把 /srv/gitlab-runner/config/config.toml 里的并发改成:
concurrent = 1
这样更稳,避免 CI 作业和 GitLab 服务争抢资源。GitLab 官方强调 Runner 的安装与资源规划应考虑安全和性能。
七、第四步:在仓库中放置两个关键文件
这两个文件必须放在 Git 仓库中,不是放在 Runner 主机的系统目录里。
目录结构应为:
demo_ai_platform/
├── .gitlab-ci.yml
└── scripts/
└── ai_review.py
GitLab 官方说明,pipeline 由仓库根目录的 .gitlab-ci.yml 配置。
八、.gitlab-ci.yml 完整示例
把下面内容保存为仓库根目录的 .gitlab-ci.yml:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- when: never
stages:
- ai_review
default:
image: python:3.11-slim
interruptible: true
variables:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_CACHE_DIR: "1"
PYTHONUNBUFFERED: "1"
ai_code_review:
stage: ai_review
before_script:
- python -V
- pip install requests
script:
- python scripts/ai_review.py
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
retry: 1
这里最关键的是:
workflow: rules限制只在 MR pipeline 中运行- job 名字叫
ai_code_review - 通过
python scripts/ai_review.py执行审查逻辑
GitLab 官方文档说明,Merge request pipelines 需要使用 merge_request_event 规则来匹配 MR 事件。
九、scripts/ai_review.py 完整示例
把下面内容保存为 scripts/ai_review.py:
import json
import os
import re
import sys
import time
import textwrap
from pathlib import Path
from typing import Dict, List, Optional, Set
import requests
CI_API_V4_URL = os.environ["CI_API_V4_URL"].rstrip("/")
PROJECT_ID = os.environ["CI_PROJECT_ID"]
MR_IID = os.environ["CI_MERGE_REQUEST_IID"]
GITLAB_TOKEN = os.environ["GITLAB_API_TOKEN"]
ZHIPU_API_KEY = os.environ["ZHIPU_API_KEY"]
MODEL = os.getenv("REVIEW_MODEL", "glm-5")
ZHIPU_BASE_URL = os.getenv("ZHIPU_BASE_URL", "https://open.bigmodel.cn/api/paas/v4").rstrip("/")
REVIEW_INLINE = os.getenv("REVIEW_INLINE", "true").lower() == "true"
MAX_FILES = int(os.getenv("REVIEW_MAX_FILES", "20"))
MAX_DIFF_CHARS = int(os.getenv("REVIEW_MAX_DIFF_CHARS", "50000"))
MAX_COMMENTS = int(os.getenv("REVIEW_MAX_COMMENTS", "8"))
SUMMARY_MARKER_PREFIX = "ai-review"
CODE_EXTS = {
".py", ".js", ".jsx", ".ts", ".tsx",
".java", ".go", ".rb", ".php",
".c", ".cc", ".cpp", ".h", ".hpp",
".cs", ".kt", ".rs", ".swift", ".scala",
".sql", ".yaml", ".yml", ".json",
".sh", ".bash", ".zsh", ".ps1",
".vue", ".css", ".scss", ".sass", ".less",
".xml", ".toml", ".ini", ".conf",
".proto", ".gradle"
}
CODE_FILENAMES = {
"Dockerfile", "dockerfile",
"Makefile", "makefile",
"Jenkinsfile",
"pom.xml"
}
HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? +(\d+)(?:,(\d+))? @@")
def log(msg: str) -> None:
print(msg, flush=True)
def request_with_retry(
method: str,
url: str,
*,
params: Optional[dict] = None,
data: Optional[dict] = None,
json_body: Optional[dict] = None,
headers: Optional[dict] = None,
timeout: int = 60,
max_attempts: int = 3,
):
last_err = None
for attempt in range(1, max_attempts + 1):
try:
resp = requests.request(
method=method,
url=url,
params=params,
data=data,
json=json_body,
headers=headers,
timeout=timeout,
)
if resp.status_code in (429, 500, 502, 503, 504):
last_err = RuntimeError(
f"{method} {url} -> {resp.status_code}: {resp.text[:800]}"
)
if attempt < max_attempts:
time.sleep(min(2 attempt, 6))
continue
resp.raise_for_status()
return resp
except requests.RequestException as exc:
last_err = exc
if attempt < max_attempts:
time.sleep(min(2 attempt, 6))
continue
raise last_err
def gitlab_get(path: str, params: Optional[dict] = None):
url = f"{CI_API_V4_URL}{path}"
resp = request_with_retry(
"GET",
url,
params=params,
headers={"PRIVATE-TOKEN": GITLAB_TOKEN},
timeout=60,
)
return resp.json(), resp.headers
def gitlab_post(path: str, data: Optional[dict] = None):
url = f"{CI_API_V4_URL}{path}"
resp = request_with_retry(
"POST",
url,
data=data,
headers={"PRIVATE-TOKEN": GITLAB_TOKEN},
timeout=60,
)
return resp.json()
def get_mr() -> dict:
data, _ = gitlab_get(f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}")
return data
def get_latest_version() -> dict:
data, _ = gitlab_get(f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}/versions")
if not data:
raise RuntimeError("No merge request versions found")
return data[0]
def get_diffs() -> List[dict]:
page = 1
all_items: List[dict] = []
while True:
items, headers = gitlab_get(
f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}/diffs",
params={"page": page, "per_page": 100, "unidiff": "true"},
)
if not items:
break
all_items.extend(items)
next_page = headers.get("X-Next-Page")
if not next_page:
break
page = int(next_page)
return all_items
def get_discussions() -> List[dict]:
page = 1
all_items: List[dict] = []
while True:
items, headers = gitlab_get(
f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}/discussions",
params={"page": page, "per_page": 100},
)
if not items:
break
all_items.extend(items)
next_page = headers.get("X-Next-Page")
if not next_page:
break
page = int(next_page)
return all_items
这个函数处理 GitLab 的分页。每次请求 100 条讨论,靠响应头里的 X-Next-Page 判断还有没有下一页。没有就直接退出。
def is_code_file(path: str) -> bool:
p = Path(path)
if p.name in CODE_FILENAMES:
return True
return p.suffix.lower() in CODE_EXTS
判断文件是不是代码文件。先看文件名是否在预设列表里,比如 Dockerfile、Makefile 这种没有扩展名但确实是代码的。再看后缀,.py、.js、.go 之类的都算。
def parse_added_lines(unified_diff: str) -> Set[int]:
added_lines: Set[int] = set()
current_new_line: Optional[int] = None
for raw in unified_diff.splitlines():
line = raw.rstrip("\n")
hunk = HUNK_RE.match(line)
if hunk:
current_new_line = int(hunk.group(1))
continue
if current_new_line is None:
continue
if line.startswith("--- ") or line.startswith("+++ "):
continue
if line.startswith("\\"):
continue
if line.startswith("+"):
added_lines.add(current_new_line)
current_new_line += 1
elif line.startswith("-"):
continue
else:
current_new_line += 1
return added_lines
这个函数从 unified diff 里提取新增行的行号。逻辑不复杂,但有几个坑得注意。
@@ -10,5 +10,7 @@ 这种 hunk 头决定了新文件从哪一行开始计数。遇到 hunk 头就重置 current_new_line。
--- 和 +++ 开头的行是文件头,跳过。\ 开头的是 diff 工具的特殊标记,比如 \ No newline at end of file,也跳过。
+ 开头的行是新增的,记录行号然后计数器加一。- 开头的行是删除的,只存在于旧文件里,新文件行号不动。其他行是上下文,计数器加一。
这样跑完一轮,added_lines 里就是所有新增行的行号。后面给 GLM 传参时会用到,确保行内评论只落在这些行上。
def build_review_input(mr: dict, diffs: List[dict]) -> (dict, Dict[str, Set[int]]):
selected: List[dict] = []
total_chars = 0
valid_line_map: Dict[str, Set[int]] = {}
for d in diffs:
new_path = d.get("new_path") or ""
diff_text = d.get("diff") or ""
if d.get("generated_file") or d.get("too_large") or d.get("collapsed"):
continue
if not is_code_file(new_path):
continue
if not diff_text.strip():
continue
trimmed_diff = diff_text[:12000]
added_lines = parse_added_lines(trimmed_diff)
if not added_lines:
continue
item = {
"old_path": d.get("old_path"),
"new_path": d.get("new_path"),
"new_file": d.get("new_file", False),
"deleted_file": d.get("deleted_file", False),
"renamed_file": d.get("renamed_file", False),
"diff": trimmed_diff,
}
projected_len = total_chars + len(trimmed_diff)
if len(selected) >= MAX_FILES or projected_len > MAX_DIFF_CHARS:
break
selected.append(item)
valid_line_map[new_path] = added_lines
total_chars = projected_len
review_input = {
"title": mr.get("title", ""),
"description": mr.get("description", ""),
"source_branch": mr.get("source_branch", ""),
"target_branch": mr.get("target_branch", ""),
"changes_count": mr.get("changes_count", ""),
"files": selected,
}
return review_input, valid_line_map
build_review_input 负责把原始 diff 数据整理成给模型用的输入。
先过滤掉几类文件:生成的代码(比如 protobuf 自动生成的)、太大的、折叠的。这些要么没意义,要么塞不进上下文窗口。
然后只保留代码文件。文档、图片、配置文件这些跳过,代码审查不需要看。
每个文件的 diff 截断到 12000 字符。超出的部分直接砍掉,避免单个文件吃光整个 token 预算。
MAX_FILES 和 MAX_DIFF_CHARS 控制总量。一旦文件数或总字符数超限就停止添加。这个策略比较粗暴,按 diff 顺序取前面的,后面的直接丢。实际用下来够用,因为大部分 MR 不会改太多文件。
返回两个东西:一个是给模型的 review_input,另一个是 valid_line_map,记录每个文件允许评论的行号集合。后面发评论时会用它来校验。
def extract_json(text: str) -> dict:
text = text.strip()
try:
return json.loads(text)
except Exception:
pass
fence_match = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.S)
if fence_match:
return json.loads(fence_match.group(1))
obj_match = re.search(r"\{.*\}", text, re.S)
if obj_match:
return json.loads(obj_match.group(0))
raise ValueError(f"Model did not return valid JSON. Raw output:\n{text[:1000]}")
模型返回的内容不一定干净。有时候会包在 markdown 代码块里,有时候前后带点解释文字。
这个函数按优先级尝试三种解析方式。先直接 json.loads,成功就返回。失败就找 ```json ... ``` 代码块,从里面提取。再失败就找第一个 { 到最后一个 } 之间的内容。
都失败就抛异常,把原始输出前 1000 字符打出来方便排查。
def normalize_model_content(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
parts.append(item.get("text", ""))
else:
parts.append(str(item))
return "".join(parts)
return str(content)
智谱的 API 有时候返回的 content 是字符串,有时候是列表。列表里每个元素可能是 dict 带 text 字段,也可能是纯字符串。
这个函数统一处理成字符串。逻辑很简单,但少了它后面解析 JSON 会莫名其妙报错。
def call_glm(review_input: dict) -> dict:
system_prompt = (
"你是一名资深代码审查工程师。\n"
"你只能基于当前 Merge Request 的 diff 进行判断。\n"
"只输出严格 JSON,不要 markdown,不要解释,不要代码块。\n"
"只指出高价值问题:正确性、异常处理、安全、性能、并发、事务/幂等、兼容性。\n"
"忽略纯格式化、命名喜好、与本次改动无关的问题。\n"
"只有当你能从当前 unified diff 明确定位到新增/修改后的 new_line 时,才输出 inline_comments。\n"
"inline_comments 只允许评论 new_line,且必须是当前 diff 中新增/修改后的行。\n"
"如果没有足够把握,请不要编造问题。"
)
system prompt 是整套逻辑里最需要反复调的部分。
第一版写得太宽松,模型什么鸡毛蒜皮都往里塞,命名不规范、空行多了少了、import 顺序不对,全是噪音。后来加了“只指出高价值问题”和“忽略纯格式化、命名喜好”,输出质量明显好转。
“只有当你能从当前 unified diff 明确定位到新增/修改后的 new_line 时,才输出 inline_comments”这句也很关键。不加的话模型会对着上下文行瞎评论,GitLab 那边行号对不上直接报错。
最后一句“如果没有足够把握,请不要编造问题”是防幻觉的。模型有时候会脑补一些不存在的 bug,加上这句能压一压。
构造好请求体之后,直接发给智谱的接口。这里用了一个 request_with_retry 包一层重试逻辑,超时给了 180 秒,毕竟大模型推理有时候确实慢。
resp = request_with_retry(
"POST",
f"{ZHIPU_BASE_URL}/chat/completions",
headers=headers,
json_body=payload,
timeout=180,
)
data = resp.json()
拿到响应先做一层防御性解析。如果 choices 结构不对,直接把原始响应截断打印出来,方便排查问题。
try:
content = data["choices"][0]["message"]["content"]
except Exception as exc:
raise RuntimeError(
f"Unexpected GLM response: {json.dumps(data, ensure_ascii=False)[:2000]}"
) from exc
content_text = normalize_model_content(content)
return extract_json(content_text)
normalize_model_content 和 extract_json 这两个工具函数后面会讲,它们负责把模型返回的内容清洗成合法的 JSON。
接下来是幂等控制。同一个 commit 不能重复评论,否则每次流水线跑一遍就刷一屏,太吵了。做法是在评论正文里埋一个 HTML 注释标记,带上 head_sha,下次跑之前先查一遍已有的 discussion 里有没有这个标记。
def discussion_contains_marker(discussion: dict, marker: str) -> bool:
for note in discussion.get("notes", []) or []:
body = note.get("body") or ""
if marker in body:
return True
return False
def already_commented_for_head(head_sha: str) -> bool:
marker = f"<!-- {SUMMARY_MARKER_PREFIX}:{head_sha} -->"
discussions = get_discussions()
return any(discussion_contains_marker(d, marker) for d in discussions)
get_discussions 就是上一节那个翻页拉取函数,这里直接复用。
发总结评论的逻辑也不复杂。先对模型返回的 summary 和 overall_risk 做兜底,risk 不在枚举里就降级成 low。然后把标记拼进正文末尾。
def post_summary(result: dict, head_sha: str) -> None:
summary = (result.get("summary") or "未发现需要特别关注的高风险问题。").strip()
risk = (result.get("overall_risk") or "low").strip().lower()
if risk not in {"low", "medium", "high"}:
risk = "low"
body = textwrap.dedent(
f"""\
### AI Code Review ({MODEL})
- Risk: {risk}
- Commit: `{head_sha}`
{summary}
<!-- {SUMMARY_MARKER_PREFIX}:{head_sha} -->
"""
).strip()
gitlab_post(
f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}/discussions",
data={"body": body},
)
行内评论比总结麻烦得多。模型给的行号不一定准,可能指向一个不存在的位置,也可能重复。GitLab 的 API 对行号很敏感,给错了整个请求就 400。所以必须做一轮清洗。
sanitize_inline_comments 接收模型返回的原始评论列表,以及一个 valid_line_map,这个 map 记录了每个文件在 diff 里哪些新行号是合法的。清洗流程大致是:过滤非 dict 项,规范化字段,校验 severity 枚举,检查行号是否在合法集合里,截断超长正文,去重,最后按 MAX_COMMENTS 截断。
def sanitize_inline_comments(
raw_comments: List[dict], valid_line_map: Dict[str, Set[int]]
) -> List[dict]:
cleaned: List[dict] = []
seen = set()
for item in raw_comments or []:
if not isinstance(item, dict):
continue
new_path = (item.get("new_path") or "").strip()
old_path = (item.get("old_path") or new_path).strip()
body = (item.get("body") or "").strip()
severity = (item.get("severity") or "medium").strip().lower()
new_line = item.get("new_line")
if severity not in {"low", "medium", "high"}:
severity = "medium"
if not new_path or not body:
continue
if not isinstance(new_line, int):
try:
new_line = int(new_line)
except Exception:
continue
if new_line <= 0:
continue
allowed_lines = valid_line_map.get(new_path, set())
if new_line not in allowed_lines:
continue
body = body[:220]
dedupe_key = (new_path, new_line, body)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
cleaned.append(
{
"new_path": new_path,
"old_path": old_path,
"new_line": new_line,
"severity": severity,
"body": body,
}
)
if len(cleaned) >= MAX_COMMENTS:
break
return cleaned
几个细节值得说一下。old_path 如果模型没给,就默认跟 new_path 一样,因为大多数情况下文件没被重命名。new_line 可能是字符串,尝试转 int,转不了就丢弃。正文硬截断到 220 字符,跟前面 prompt 里给的约束对齐。去重 key 用的是路径加行号加正文,同一位置同一句话只保留一条。
valid_line_map 从哪来?下一节讲怎么从 MR 的 diff 里解析出每个文件的可评论行号集合。
def post_inline_comments(comments: List[dict], version: dict) -> None:
for item in comments:
payload = {
"body": f"[AI][{item['severity']}] {item['body']}",
"position[position_type]": "text",
"position[base_sha]": version["base_commit_sha"],
"position[head_sha]": version["head_commit_sha"],
"position[start_sha]": version["start_commit_sha"],
"position[new_path]": item["new_path"],
"position[old_path]": item["old_path"],
"position[new_line]": str(item["new_line"]),
}
try:
gitlab_post(
f"/projects/{PROJECT_ID}/merge_requests/{MR_IID}/discussions",
data=payload,
)
except Exception as exc:
log(f"Skip inline comment {item['new_path']}:{item['new_line']} due to API error: {exc}")
def main() -> None:
log("AI review started")
mr = get_mr()
head_sha = ((mr.get("diff_refs") or {}).get("head_sha")) or "unknown"
if already_commented_for_head(head_sha):
log(f"AI review already posted for head_sha={head_sha}, skip duplicate run.")
return
diffs = get_diffs()
review_input, valid_line_map = build_review_input(mr, diffs)
if not review_input["files"]:
log("No reviewable code diffs found.")
return
log(f"Selected {len(review_input['files'])} files for review.")
result = call_glm(review_input)
post_summary(result, head_sha)
log("Summary discussion posted.")
if REVIEW_INLINE:
version = get_latest_version()
inline_comments = sanitize_inline_comments(
result.get("inline_comments", []),
valid_line_map,
)
if inline_comments:
post_inline_comments(inline_comments, version)
log(f"Posted {len(inline_comments)} inline comments.")
else:
log("No valid inline comments to post.")
else:
log("Inline comments disabled by REVIEW_INLINE=false.")
log("AI review finished.")
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"AI review failed: {exc}", file=sys.stderr, flush=True)
raise
脚本用到的接口都是 GitLab 官方 REST API 里现成的:
- 读 MR 和 diff:Merge requests API
- 读 diff versions:Merge requests API
- 写总评和行内评论:Discussions API
GitLab 官方文档里写得很清楚,这些 API 就是拿来干自动化代码评审的。(GitLab 文档)
十、脚本为什么这样设计
整份脚本做了 5 个关键处理:
1. 只审查“可读代码文件”
它会过滤出常见代码扩展名,跳过过大的、折叠的、自动生成的 diff。GitLab 的 MR diff API 会返回 generated_file、collapsed、too_large 这些字段。(GitLab 文档)
2. 只在 MR 中运行
脚本依赖这几个 GitLab 预定义变量:
CI_API_V4_URLCI_PROJECT_IDCI_MERGE_REQUEST_IID
这些变量在 CI job 里直接当环境变量用就行。GitLab 官方说明,CI/CD 变量会以环境变量形式暴露给 job。(GitLab 文档)
3. 只对新增行做行内评论
脚本会解析 unified diff 的 hunk 信息,只允许评论当前 diff 中真正新增或修改的 new_line。GitLab 的 Discussions API 支持基于 diff position 创建行内讨论。(GitLab 文档)
4. 避免重复发总评
脚本在总评里加了一个 HTML marker,并在新一次运行前先查询已有 discussion,避免同一个 head_sha 重复灌评论。
5. 先总评,再行内评论
第一轮联调建议先关掉 REVIEW_INLINE,只验证总评;总评通过后再开行内评论。
十一、第五步:本地准备 Git 仓库
假设你已经把项目 clone 到本地,例如:
~/workspace/demo_ai_platform
进入项目:
cd ~/workspace/demo_ai_platform
1. 配置 Git 作者信息
第一次在这台机器上提交代码前,需要配置身份:
git config --global user.name "your_name"
git config --global user.email "your_email@example.com"
2. 创建测试分支
git checkout main
git pull origin main
git checkout -b test/ai-code-review-smoke
这里建议始终用:
git push -u origin HEAD
来推当前分支,避免手误推错分支名。
十二、第六步:通过一个快排测试文件验证 AI Review
为了让 AI 确实“有东西可审”,不要只提交 .gitlab-ci.yml 和脚本。再加一个带明显逻辑问题的 Python 文件。
新建 quicksort_demo.py:
def quick_sort(nums):
if len(nums) <= 1:
return nums
pivot = nums[0]
left = [x for x in nums[1:] if x < pivot]
right = [x for x in nums[1:] if x > pivot]
return quick_sort(left) + [pivot] + quick_sort(right)
if __name__ == "__main__":
data = [5, 3, 5, 2, 1, 3]
print("origin:", data)
print("sorted:", quick_sort(data))
这里故意留了一个比较典型的缺陷:和 pivot 相等的元素会丢失,因为它们既不在 left 里,也不在 right 里。
十三、第七步:第一轮联调,只测“总评”
1. 先确保变量是:
REVIEW_INLINE=false
2. 提交并推送
git add .gitlab-ci.yml scripts/ai_review.py quicksort_demo.py
git commit -m "test: add ai code review pipeline and quicksort demo"
git push -u origin HEAD
3. 创建 Merge Request
在 GitLab 中创建一个 MR:
- Source branch:
test/ai-code-review-smoke - Target branch:
main
MR 标题可以写:
test: AI Code Review with quicksort demo
4. 等待 MR Pipeline 触发
GitLab 的 MR pipeline 会在 MR 事件下运行。只有当 MR 中真的有 commits 和 changes 时,pipeline 才会出现。(GitLab 文档)
5. 看日志
进入:
MR → Pipelines → ai_code_review
如果正常,你会看到类似:
python -V
pip install requests
python scripts/ai_review.py
AI review started
Selected X files for review.
Summary discussion posted.
Inline comments disabled by REVIEW_INLINE=false.
AI review finished.
只要看到:
Summary discussion posted.AI review finished.
就说明第一轮通过了。
6. 回到 MR 页面看总评
如果成功,MR 页面会新增一条 AI 自动总评。
十四、第八步:第二轮联调,再测“行内评论”
1. 把变量改成:
REVIEW_INLINE=true
2. 重新触发 MR pipeline
最简单的方法是给同一个分支推一个空提交:
git commit --allow-empty -m "test: rerun pipeline with inline review enabled"
git push
GitLab 每次向 MR 的源分支 push 新内容,都会创建新的 diff version。(GitLab 文档)
3. 再看 job 日志
如果正常,日志里可能出现:
Posted 1 inline comments.
或者:
Posted 2 inline comments.
4. 去 MR 的 Changes 页面看
如果行内评论成功,你会在 quicksort_demo.py 的具体代码行旁边看到 AI 留下的 review comment。
十五、如何判断 Runner 确实在运行
你可以从 3 个地方看。
1. 在 MR 页面看 Pipeline 状态
如果页面出现:
Pipelines 1Merge request pipeline #... running
说明 MR pipeline 已经被触发并开始执行。
2. 在 Job 日志里看实时输出
如果你能看到日志滚动,比如:
python scripts/ai_review.pyAI review started
那就说明 Runner 已经接到任务并开始执行。
3. 在 Settings → CI/CD → Runners 查看
找到你注册的 ai-review-runner,确认它是 online 状态。
GitLab 官方说明,Runner 是执行 CI/CD jobs 的组件,job 只有被 Runner 接走后才会真正开始跑。(GitLab 文档)
十六、最常见的 8 个问题与排查方法
1. MR 页面显示 Merge request contains no changes
说明源分支和目标分支没有差异。
先确认你已经成功:
git commitgit push
如果 MR 的 Commits、Changes、Pipelines 都是 0,就还没到测试阶段。
2. git commit 失败,提示 Author identity unknown
说明本机没配置 Git 作者信息。
执行:
git config --global user.name "your_name"
git config --global user.email "your_email@example.com"
3. git push 报 403
说明你当前账号没有该项目的推送权限,或本地凭据错了。
需要确认:
- 你在项目里是否至少是
Developer - 本地保存的 HTTP 账号 / Token 是否正确
4. Pipeline 一直 pending
说明 Runner 没接到任务。
重点检查:
- Runner 是否 online
- Runner 是否允许
Run untagged jobs - Runner 是否被 pause
- job 是否被错误的 tag 限制
5. 日志里报 401 / 403
重点检查:
GITLAB_API_TOKENZHIPU_API_KEY- GitLab token 是否有
apiscope - token 是否过期
6. 日志里报 No reviewable code diffs found
说明这次 MR 改动没有可审查代码文件。
不要只改 README 或空目录。
7. 有总评,没有行内评论
通常并不代表失败,只是这次模型没有输出合法的 inline_comments,或者返回的行号被脚本过滤掉了。
8. 同一个 MR 里重复出现多条相同总评
说明你的脚本没有做去重,或你用的不是带 marker 的版本。本文提供的脚本已经处理了这一点。
十七、关于“GitLab 与 Runner 是否能部署在同一台机器”
答案是:可以,但不推荐作为长期生产方案。
GitLab 官方明确建议出于安全和性能原因,把 Runner 安装在与 GitLab 实例不同的机器上。(GitLab 文档)
如果只是测试或内网小规模使用,同机部署也能工作,但建议:
concurrent = 1privileged = false- 定期清理 Docker 缓存
- 尽量不要让 Runner 和生产业务容器混跑
十八、关于智谱 GLM-5 的调用说明
本文使用的是智谱官方的对话补全接口:
POST /api/paas/v4/chat/completions
关键字段包括:
model: "glm-5"messagestemperaturemax_tokensstream
智谱官方当前文档中,glm-5 是可用模型之一,接口使用 Bearer 认证。(智谱AI开放文档)
十九、一套最推荐的实际执行顺序
如果你要按最稳的方式推进,建议这样做:
阶段 1:准备
先完成:
- 项目访问令牌
- CI/CD Variables
- Runner 注册
- 把
.gitlab-ci.yml和scripts/ai_review.py放进仓库
阶段 2:第一轮联调
设置:
REVIEW_INLINE=false
然后:
- 建测试分支
- 新建
quicksort_demo.py - commit
- push
- 创建 MR
- 看
ai_code_reviewjob 日志 - 确认 MR 中出现 AI 总评
阶段 3:第二轮联调
设置:
REVIEW_INLINE=true
然后:
- 对同一 MR 分支再 push 一个新提交
- 看 job 日志是否出现
Posted X inline comments. - 去
Changes页面看行内评论
阶段 4:正式上线
当你确认:
- token 正常
- runner 稳定
- 总评稳定
- 行内评论稳定
再把测试分支的内容合并到 main,正式启用。
二十、最后给你的一个落地建议
第一次做成之后,不要急着把 AI Review 当成“拦截合并”的强约束。
先把它当成:
- 自动审查助手
- 高风险问题提示器
- reviewer 的辅助工具
等你观察几轮误报率、漏报率和团队接受度,再决定是否把它接入更严格的门禁流程。
如果你愿意,我下一步可以把这份指南继续整理成两种版本:“面向新手的超简版”和“面向运维/平台团队的企业部署版”。
常见问题(FAQ)
GitLab 接入 AI 代码审查一定要自己搭 Web 服务吗?
不需要。推荐用 MR Pipeline + Python 脚本 + GitLab API + GLM-5 的方案,不用公网回调地址,也不用自建 Web 服务,日志全在 GitLab 里,出问题直接翻 job log,跑通后再升级成 webhook 也不难。
GitLab 项目访问令牌该怎么配置才安全?
在 Settings → Access tokens 创建,名称如 ai-review-bot,Role 选 Developer,Scopes 只勾 api。令牌值只展示一次要立即保存,记为 GITLAB_API_TOKEN,并在 CI/CD Variables 中设为 Masked and hidden。
AI 代码审查的行内评论第一轮要不要开启?
建议先关掉。把 REVIEW_INLINE 设为 false,先只测 AI 总评,跑通总评后再开行内评论。因为行内评论更容易出错,第一轮先跑通主流程能减少排查成本。
版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。
文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。
若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。



