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

  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 运行环境。(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_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 只会在受保护分支或标签的流水线中可用。(GitLab 文档)

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>

认证。(智谱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_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 的辅助工具

等你观察几轮误报率、漏报率和团队接受度,再决定是否把它接入更严格的门禁流程。

如果你愿意,我下一步可以把这份指南继续整理成两种版本:“面向新手的超简版”“面向运维/平台团队的企业部署版”

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐