GEOZ

用 GitLab MR Pipeline 接入 GLM-5 自动代码审查:不用自建 Web 服务

2026/9/22
用 GitLab MR Pipeline 接入 GLM-5 自动代码审查:不用自建 Web 服务

AIAI Summary (BLUF)

这篇教程手把手教你在 GitLab 中接入 AI 代码审查。通过配置 MR Pipeline、GitLab Runner 和 Python 脚本,让 GLM-5 自动读取代码差异并写回审查评论。适合想为团队引入自动化代码审查的开发者。

核心洞察

MR Pipeline 做 AI Code Review,最大的好处是不用自己搭 Web 服务、不用管公网回调,所有日志都在 GitLab 里,出了问题直接翻 job log 就行。这篇文章手把手从建 token 到写 CI 配置全部走了一遍,跟着做基本不会卡住。唯一要注意的是行内评论那部分,第一轮建议先关掉,跑通总评再说。


核心结论

  1. 用 MR Pipeline 做 AI Code Review 的核心链路是:开发者提交 MR → GitLab MR Pipeline 触发 → Runner 执行 Python 脚本 → 脚本读取 MR diff → 调用 GLM-5 → 通过 GitLab Discussions API 写回评论。

  2. 推荐新手采用「MR Pipeline + Python 脚本 + GitLab API + GLM-5」方案,无需公网回调地址、无需自建 Web 服务,所有日志都在 GitLab job log 中,排查更方便。

  3. 接入前需准备 4 样东西:一个 GitLab 项目、一个可用的 GitLab Runner、一个 GitLab 项目访问令牌(Scopes 只勾选 api,Role 用 Developer)、一个智谱 GLM-5 的 API Key。

  4. 第一轮测试建议将 REVIEW_INLINE 设为 false,先只跑通 AI 总评,暂不开启更容易出错的行内评论。

  5. 关键配置变量包括 REVIEW_MODEL=glm-5REVIEW_MAX_FILES=20REVIEW_MAX_DIFF_CHARS=50000REVIEW_MAX_COMMENTS=8;敏感变量 GITLAB_API_TOKENZHIPU_API_KEY 应设为 Masked and hidden 且不勾选 Protect。

GitLab 中接入 AI Code Review 完整指南

本文目标是为一个示例项目接入自动化 AI Code Review,效果是:

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 样东西:

  1. 一个 GitLab 项目,本文用 example_team/demo_ai_platform
  2. 一个可用的 GitLab Runner
  3. 一个 GitLab 项目访问令牌
  4. 一个智谱 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 Variables

进入:

Settings → CI/CD → Variables

需要创建这些变量。

1. 敏感变量

创建:

  • GITLAB_API_TOKEN
  • ZHIPU_API_KEY

建议设置为:

  • Type:Variable
  • Environment:All (default)
  • Visibility:Masked and hidden
  • Protect variable:不要勾选
  • Expand variable reference:不要勾选

GitLab 官方说明,变量可以设置为 MaskedMasked and hidden;隐藏后变量仍可在流水线中使用,但不能再在 UI 中显示原值。官方也说明,Protected variable 只会在受保护分支或标签的流水线中可用。

2. 普通配置变量

创建:

  • REVIEW_MODEL=glm-5
  • REVIEW_INLINE=false
  • REVIEW_MAX_FILES=20
  • REVIEW_MAX_DIFF_CHARS=50000
  • REVIEW_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

判断文件是不是代码文件。先看文件名是否在预设列表里,比如 DockerfileMakefile 这种没有扩展名但确实是代码的。再看后缀,.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_FILESMAX_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_contentextract_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 就是上一节那个翻页拉取函数,这里直接复用。

发总结评论的逻辑也不复杂。先对模型返回的 summaryoverall_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_filecollapsedtoo_large 这些字段。(GitLab 文档

2. 只在 MR 中运行

脚本依赖这几个 GitLab 预定义变量:

  • CI_API_V4_URL
  • CI_PROJECT_ID
  • CI_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 1
  • Merge request pipeline #... running

说明 MR pipeline 已经被触发并开始执行。

2. 在 Job 日志里看实时输出

如果你能看到日志滚动,比如:

  • python scripts/ai_review.py
  • AI 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 commit
  • git push

如果 MR 的 CommitsChangesPipelines 都是 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_TOKEN
  • ZHIPU_API_KEY
  • GitLab token 是否有 api scope
  • token 是否过期

6. 日志里报 No reviewable code diffs found

说明这次 MR 改动没有可审查代码文件。
不要只改 README 或空目录。

7. 有总评,没有行内评论

通常并不代表失败,只是这次模型没有输出合法的 inline_comments,或者返回的行号被脚本过滤掉了。

8. 同一个 MR 里重复出现多条相同总评

说明你的脚本没有做去重,或你用的不是带 marker 的版本。本文提供的脚本已经处理了这一点。


十七、关于“GitLab 与 Runner 是否能部署在同一台机器”

答案是:可以,但不推荐作为长期生产方案

GitLab 官方明确建议出于安全和性能原因,把 Runner 安装在与 GitLab 实例不同的机器上。(GitLab 文档

如果只是测试或内网小规模使用,同机部署也能工作,但建议:

  • concurrent = 1
  • privileged = false
  • 定期清理 Docker 缓存
  • 尽量不要让 Runner 和生产业务容器混跑

十八、关于智谱 GLM-5 的调用说明

本文使用的是智谱官方的对话补全接口:

POST /api/paas/v4/chat/completions

关键字段包括:

  • model: "glm-5"
  • messages
  • temperature
  • max_tokens
  • stream

智谱官方当前文档中,glm-5 是可用模型之一,接口使用 Bearer 认证。(智谱AI开放文档


十九、一套最推荐的实际执行顺序

如果你要按最稳的方式推进,建议这样做:

阶段 1:准备

先完成:

  • 项目访问令牌
  • CI/CD Variables
  • Runner 注册
  • .gitlab-ci.ymlscripts/ai_review.py 放进仓库

阶段 2:第一轮联调

设置:

  • REVIEW_INLINE=false

然后:

  • 建测试分支
  • 新建 quicksort_demo.py
  • commit
  • push
  • 创建 MR
  • ai_code_review job 日志
  • 确认 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 总评,跑通总评后再开行内评论。因为行内评论更容易出错,第一轮先跑通主流程能减少排查成本。

阿凯广州
本文由 阿凯 审核,最后更新于 2026年9月22日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。