Git08-代码Review01:GitLab 中接入 AI Code Review 完整指南
GitLab 中接入 AI Code Review 完整指南
本文目标是为一个示例项目接入自动化 AI Code Review,效果是:
- 开发者发起 Merge Request
- GitLab 自动触发 MR Pipeline
- Pipeline 调用 GLM-5
- AI 自动把总评和可选的行内评论写回 MR
GitLab 官方说明,Merge request pipelines 可以在创建或更新 MR 时运行专门的 CI/CD 任务;CI/CD 任务由仓库根目录的 .gitlab-ci.yml 配置。(GitLab 文档)
一、最终效果与示例命名
为了避免和你的真实项目混淆,本文统一使用这些示例名称:
- 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 都支持这种自动化集成。(GitLab 文档)
二、开始前你需要准备什么
先准备 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 运行环境。(GitLab 文档)
三、推荐的整体方案
对新手来说,最稳的方案不是先做 Webhook 服务,而是先做:
MR Pipeline + Python 脚本 + GitLab API + GLM-5
原因很简单:
- 不需要公网回调地址
- 不需要自建 Web 服务
- 日志全部在 GitLab 里
- 出问题更容易排查
- 跑通后再升级成 webhook 方案也不难
GitLab 官方把 pipeline 作为 CI/CD 的基础机制,允许在创建 MR 等事件时自动运行。(GitLab 文档)
四、第一步:创建 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 权限。(GitLab 文档)
- 用
Developer通常够用,不建议一开始就给更高权限。 - 令牌值只会在创建后展示一次,应该立即保存。(GitLab 文档)
建议把这个 token 记为:
GITLAB_API_TOKEN
五、第二步:在 GitLab 中配置 CI/CD Variables
进入:
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 只会在受保护分支或标签的流水线中可用。(GitLab 文档)
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>
认证。(智谱AI开放文档)
六、第三步:准备 GitLab Runner
GitLab 官方建议:Runner 最好安装在与 GitLab 实例不同的机器上,这是出于安全和性能考虑。(GitLab 文档)
但如果你只是测试或小规模使用,也可以在同一台 Ubuntu 22.04 机器上通过 Docker 运行 Runner。GitLab 官方支持把 Runner 作为 Docker 容器运行,并支持 Docker executor。(GitLab 文档)
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 的方式。(GitLab 文档)
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。(GitLab 文档)
4. 建议把并发限制为 1
如果 Runner 和 GitLab 在同一台主机上运行,建议把 /srv/gitlab-runner/config/config.toml 里的并发改成:
concurrent = 1
这样更稳,避免 CI 作业和 GitLab 服务争抢资源。GitLab 官方强调 Runner 的安装与资源规划应考虑安全和性能。(GitLab 文档)
七、第四步:在仓库中放置两个关键文件
这两个文件必须放在Git 仓库中,不是放在 Runner 主机的系统目录里。
目录结构应为:
demo_ai_platform/
├── .gitlab-ci.yml
└── scripts/
└── ai_review.py
GitLab 官方说明,pipeline 由仓库根目录的 .gitlab-ci.yml 配置。(GitLab 文档)
八、.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 事件。(GitLab 文档)
九、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
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
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
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
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]}")
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)
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"
"如果没有足够把握,请不要编造问题。"
)
user_payload = {
"task": "review_gitlab_merge_request",
"output_schema": {
"summary": "string",
"overall_risk": "low|medium|high",
"inline_comments": [
{
"new_path": "string",
"old_path": "string",
"new_line": 1,
"severity": "low|medium|high",
"body": "string"
}
]
},
"constraints": {
"max_comments": MAX_COMMENTS,
"language": "zh-CN",
"body_max_chars": 220
},
"merge_request": review_input
}
headers = {
"Authorization": f"Bearer {ZHIPU_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
],
"stream": False,
"temperature": 0.2,
"max_tokens": 4096,
}
resp = request_with_retry(
"POST",
f"{ZHIPU_BASE_URL}/chat/completions",
headers=headers,
json_body=payload,
timeout=180,
)
data = resp.json()
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)
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)
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},
)
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
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 的辅助工具
等你观察几轮误报率、漏报率和团队接受度,再决定是否把它接入更严格的门禁流程。
如果你愿意,我下一步可以把这份指南继续整理成两种版本:“面向新手的超简版”和“面向运维/平台团队的企业部署版”。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)