30-模块四-AI代码审核实战 第30讲-模块实战 - CodeSentinel 自动审核管线完整实现(从 PR 提交到审核报告)

2 阅读24分钟

模块四-AI代码审核实战 | 第30讲:模块实战 - CodeSentinel 自动审核管线完整实现(从 PR 提交到审核报告)

本讲目标:把模块四散落能力收敛为一条可运行的端到端链路:从 GitHub Webhook 接收 PR 事件,到拉取 diff、分块、运行多阶段审核(安全、架构合规、性能、质量、AGENTS.md 合规),再到聚合 ReviewReport、格式化 Markdown 评论、写回 PR 与提交状态;附带集成测试(mock webhook)与 Docker Compose 交付物,以及 curl 演示脚本。本讲是模块四的「集成交付」,强调可观测、可重试、可扩展。请预留至少两小时动手:一小时跑通 demo,一小时把 module4 测试改写到你的仓库路径上。


开场:模块四的终点不是更多规则,而是一条可靠管线

如果你完成了前几讲却停在「各自能跑」,团队感知仍然是碎片化工具:安全扫描归安全,性能提示归性能,规范合规归文档。研发真正需要的是:一次 PR 推送,一次清晰结论。结论应当分层:哪些必须修、哪些建议修、哪些属于信息提示;同时给出可点击的定位与可审计的记录。本讲要把 CodeSentinel 推过这道门槛,变成「像 CI 一样默认存在」的基础设施。

我们会实现一个显式编排器 ReviewPipeline:它不做具体规则细节,而是定义阶段(stage)契约、统一输入输出、聚合结果、生成报告。各阶段分别对应你在模块四已建立的心智模型:安全审核(第 26 讲占位实现)、架构合规(第 25 讲占位实现)、性能检测(第 27 讲 PerformanceChecker 思路落地简化版)、质量评分(第 28 讲 QualityScorer)、以及模块三的 AGENTS.md 合规(以最小启发式占位,避免本讲篇幅失控)。占位实现不是偷懒,而是教学上的「插拔点」:你能在不推翻架构的情况下替换为真实规则引擎与 LLM 调用。

完成本讲后,你应能演示一次完整闭环:推送 PR → Webhook 触发 → 状态 pending → 审核完成 → 评论出现 → 状态 success/failure。并且你能解释:为什么编排器要幂等、为什么评论要分层、为什么集成测试必须 mock 平台 API。下面先给出总体架构与端到端时序,再给出完整代码与 Compose。

在落地时,请把「演示成功」与「生产可用」刻意分开。演示成功只需要一条路径跑通;生产可用需要失败重试、权限边界、机密管理、限流、可观测性与回滚策略。本讲代码同时服务两者:核心结构按生产思路写,但部署形态保持教学简洁。你应当能指出哪些行在真实环境必须替换(例如同步 httpx、缺少队列、以及过于天真的密钥正则)。这也是 AI 架构师常见工作:不是写不出 demo,而是知道 demo 离生产还差哪些硬条件。

另外,本讲刻意把 Markdown 代码围栏与 Python 源码中的反引号处理干净,避免「文档里的代码块被提前截断」这种低级但致命的问题。你在维护自己的课程文档或内部 wiki 时,也应建立同样规范:长代码优先放仓库文件,文档只引用路径;或在生成代码时避免三反引号字面量。仓库中的 module4/ 目录就是为解决这个问题而准备的「单一真相来源」。


全局视角:模块四完整管线架构(Mermaid)

flowchart TB
  subgraph Edge["边缘层"]
    WH["GitHub Webhook\nFastAPI"]
    SIG["验签"]
  end

  subgraph Core["CodeSentinel 核心"]
    DP["DiffParser\n(unified diff → chunks)"]
    RP["ReviewPipeline\norchestrator"]
    S1["SecurityStage"]
    S2["ArchitectureStage"]
    S3["PerformanceStage"]
    S4["QualityStage"]
    S5["AgentsComplianceStage"]
    AGG["ReviewReport\naggregator"]
    FMT["GitHubCommentFormatter"]
  end

  subgraph Ext["外部系统"]
    GHA["GitHub REST API"]
    REV["codesentinel-clean-lab\n/reviews 可选"]
  end

  WH --> SIG --> DP --> RP
  RP --> S1 --> AGG
  RP --> S2 --> AGG
  RP --> S3 --> AGG
  RP --> S4 --> AGG
  RP --> S5 --> AGG
  AGG --> FMT --> GHA
  AGG -.-> REV

端到端序列:从 PR 事件到评论回写(Mermaid)

sequenceDiagram
  participant Dev as 研发推送
  participant GH as GitHub
  participant CS as CodeSentinel
  participant Pipe as ReviewPipeline
  participant API as GitHub API

  Dev->>GH: git push (PR 更新)
  GH->>CS: POST webhook pull_request
  CS->>API: statuses pending
  CS->>API: GET pulls/{n}/files
  CS->>Pipe: run(change_set)
  Pipe->>Pipe: stages 并行/串行
  Pipe-->>CS: ReviewReport
  CS->>API: POST review (summary+inline)
  CS->>API: statuses success/failure
  GH-->>Dev: PR 页面展示结果

组件交互:编排器与阶段契约(Mermaid)

flowchart LR
  subgraph Stage["统一阶段接口"]
    IN["ChangeChunk 列表"]
    FN["run_stage(ctx)"]
    OUT["StageResult\nfindings + metrics"]
  end

  IN --> FN --> OUT
  OUT --> MERGE["合并去重\n(rule_id,file,line)"]
  MERGE --> POLICY["严重级别策略\nblocking 判定"]
  POLICY --> REPORT["ReviewReport"]

核心原理:为什么需要编排器、分块器与报告聚合器

1. 编排器的价值:稳定契约比规则数量更重要

规则会持续增长,但 PR 审核的「输入/输出」应稳定:ChangeSet 进,ReviewReport 出。编排器负责调度、超时、并行、降级与指标采集。没有编排器,你会在 webhook 处理函数里看到无限膨胀的 if/else。

2. diff 分块:让审核器专注局部上下文

把整个 PR 拼成一个大字符串喂给 LLM 成本高且定位差。应切成 (path, patch, added_lines) 的块,对每块运行轻量规则;需要跨文件关联的规则(架构层)再在第二阶段做仓库级快照输入。

3. 聚合与去重:同一行多条规则

合并键建议 (rule_id, path, line, message_hash)。否则评论会重复刷屏。

4. blocking 策略:哪些真正阻止合并

通常只有 highconfidence>=阈值 的安全问题与确定性密钥泄露应 blocking;性能与质量默认非阻塞,由团队策略配置。

5. 与模块二聚合的关系

内部 review_id 记录与平台评论是双写:平台给工程师看,内部给审计与统计看。

6. 与第 29 讲 webhook 的关系

webhook 层应薄:验签、解析元数据、投递任务、快速返回。本讲示例为可读性保留同步路径,注释标明生产异步化。

7. Docker Compose 的定位

Compose 拉起 review 服务(可选)与 CodeSentinel 网关(本讲 demo),让你一条命令演示。生产应换 K8s,但 Compose 是最佳教学交付。

8. 集成测试策略

使用 httpx.ASGITransport 调用 FastAPI app,mock GitHub API 用 respx 或自定义 httpx.MockTransport。本讲用 unittest.mock.patch 降低依赖。

9. Markdown 评论模板

用徽章表示严重级别:🔴 HIGH🟡 MED🔵 LOW,并附 rule_idstage

10. 可观测性

每个 stage 记录耗时;报告输出 timings_ms

11. 扩展点

新增 stage = 实现接口 + 注册列表;无需修改聚合器核心。

12. 安全

报告生成前对 message 做密钥扫描二次过滤。

13. 大 PR 策略

超过 N 个文件只跑 P0 规则,其余标记 partial。

14. 与 AGENTS.md

教学占位检测文件存在性与关键标题;真实实现应接入模块三解析服务。

15. 失败降级

某 stage 异常不应拖垮整体:记录 stage_error 并继续。

16. 版本

pipeline_version 写入报告,便于回归。

17. 与 LangChain

本讲不调用 LLM,把钩子留在 ctx.llm 可选字段。

18. 与多平台

GitHubCommentFormatter 可抽象为 CommentFormatter

19. 与缓存

(repo, head_sha) 结果缓存可避免重复审核。

20. 与人工审核

自动评论应声明「补充而非替代人工」。

21. 与速率限制

stage 内部应自带重试;编排器记录 429。

22. 与隐私

日志不打全量 patch。

23. 与合规

对外部模型发送 diff 需审批。

24. 与测试数据

fixture 使用最小复现 patch。

25. 与发布

镜像版本与 git tag 对齐。

26. 与回滚

pipeline 版本开关。

27. 与监控

Prometheus 导出 stage 耗时直方图。

28. 与告警

连续失败触发 on-call。

29. 与文档

README 给 curl 示例。

30. 与团队培训

演示脚本作为培训材料。

31. 与「AI 架构师」主题

管线体现平台架构:编排、适配、聚合、可观测。

32. 与模块五衔接

下一模块可讲多仓库治理与策略中心。

33. 与真实安全扫描对接

SecurityStage 替换为 bandit/semgrep 结果转换器。

34. 与性能分析对接

PerformanceStage 替换为第 27 讲完整检测器 import。

35. 与质量门禁对接

QualityStage 输出写入 Checks 多个 context。

36. 与架构图生成

ArchitectureStage 可输出 mermaid 草稿(占位)。

37. 与 RBAC

未来在 webhook 增加组织校验。

38. 与多环境

compose 用 .env

39. 与 CI

GitHub Actions 可调用同 pipeline CLI。

40. 与开发者体验

评论里给 details_url

41. 分阶段发布:从 dry-run 到 blocking

模块四上线建议分三档。第一档 dry-run:管线运行、报告生成,但只写内部日志,不回写 PR。第二档 advisory:回写评论与 neutral/success 状态,不阻塞合并。第三档 gate:对高危安全 finding 触发 failure 状态并配合分支保护规则。每一次档位升级都应附带一周观察期与回滚开关。

42. 「占位 stage」如何防止变成永久债

教学代码里 architecture/agents 常是弱规则。要在文件头与平台文档明确标注 experimental,并在 issue 里建立替换里程碑:例如「Q2 接入 Semgrep」「Q3 接入模块三标准服务」。否则团队会默认占位规则等于正式规则,产生错误安全感。

43. 报告版本与评论版本的对应

pipeline_version 变更时,评论模板可能也变更。建议在摘要底部追加 CodeSentinel pipeline: module4-1.0,避免工程师拿旧评论对照新规则产生误解。

44. 与 LLM 的衔接点(不在本讲实现)

ReviewContext 增加可选字段 repo_policyllm_mode 后,可在聚合前对 findings 做去噪与合并解释。注意:LLM 输出不应直接作为 blocking 依据,除非经过确定性校验器把关。

45. 与多租户 SaaS 的租户隔离

owner/repo 不足以隔离租户;需要 installation_id 或组织 UUID 作为分区键。审核结果与凭据必须同学租户,否则会造成跨租户数据泄露。

46. 与「仅变更文件」策略

全量规则跑在超大仓库上不现实。默认只对 chunks 覆盖的文件运行重规则;对仓库级架构规则可以每日夜间全量扫描,PR 只做增量对齐。

47. 与二进制与生成代码

parse_github_files_json 跳过无 patch 的文件。生成代码目录应配置忽略,避免无意义告警淹没真问题。

48. 与 PR 模板联动

在 PR 模板要求填写「风险评估」「测试类型」「是否涉及密钥」。管线可解析 PR body 的 checkbox(弱)或要求结构化字段(强)。这能把流程从工具层提升到协作层。

49. 与 Code Owners 的通知策略

当 blocking 发生时,评论 @ code owners 可能有效,但也可能骚扰。更温和策略是只在摘要里列出相关路径前缀,让 GitHub 自动提示。

50. 与「审核耗时」产品化

在摘要中展示 timings_ms 不只是调试信息,也是让团队理解「为什么有时慢」:慢通常来自新增 stage 或冷启动。把耗时透明化,能推动优化而不是抱怨。

51. 与回归集

准备 20 个黄金用例 PR fixture:每个对应一条规则触发。每次改 pipeline 跑回归,避免编排器改动导致静默漏报。

52. 与法务:自动评论的法律属性

自动评论是否构成「正式审查意见」取决于公司治理政策。建议在文档声明:自动审核是辅助工具,最终合并决策仍由责任人承担。

53. 与开源仓库

公开仓库不要在评论里泄露内部链接。target_url 应指向公开状态页或脱敏文档。

54. 与依赖供应链

pipeline 镜像构建应锁定依赖哈希;避免 @latest 镜像标签导致不可复现。

55. 与跨团队复用

ReviewPipeline 做成库发布到内部 PyPI,webhook 服务只做 I/O。这样 CLI、CI、Batch 任务可复用同一核心。

56. 与「架构治理」一致性

ArchitectureStage 的占位规则应与第 25 讲的架构原则一致:入口层依赖方向、包边界、禁止循环依赖等。占位只是简化,不代表方向相反。

57. 与「性能治理」一致性

PerformanceStage 的占位应与第 27 讲一致:async 阻塞是 high 候选,但是否 blocking 由策略决定。本讲默认 performance finding 不 blocking,避免教学示例误伤。

58. 与「质量治理」一致性

QualityStage 占位用「新增行数」只是示意;真实应接入第 28 讲评分并展示等级。

59. 与「安全治理」一致性

sec.secret_marker 属于确定性高信号,但仍有误报(测试用例里的假密钥)。需要 allowlist 与 pragma: allowlist secret 之类机制。

60. 与最终演示脚本

把 curl 演示写入团队 Wiki,并在季度复盘播放录屏:从 push 到评论出现的 30 秒,是平台价值最直观的传播方式。


代码实战:单文件可运行管线 + 测试 + Compose(建议保存为 pipeline_app.py

依赖:fastapi uvicorn httpx pydantic

# pipeline_app.py
from __future__ import annotations

import dataclasses
import hashlib
import json
import os
import time
import uuid
from typing import Any, Protocol

import httpx
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field


# ------------- Models -------------


class ChangeChunk(BaseModel):
    model_config = ConfigDict(frozen=True)

    path: str
    patch: str


@dataclasses.dataclass(frozen=True)
class Finding:
    stage: str
    rule_id: str
    severity: str  # low|medium|high
    path: str
    line: int | None
    message: str
    blocking: bool = False


@dataclasses.dataclass
class StageResult:
    stage: str
    findings: list[Finding]
    metrics: dict[str, Any]


@dataclasses.dataclass
class ReviewReport:
    report_id: str
    repo: str
    pr_number: int
    head_sha: str
    blocking: bool
    summary_md: str
    findings: list[Finding]
    timings_ms: dict[str, int]
    pipeline_version: str = "module4-1.0"


class ReviewContext(BaseModel):
    owner: str
    repo: str
    pr_number: int
    head_sha: str
    chunks: list[ChangeChunk] = Field(default_factory=list)


class StageFn(Protocol):
    def __call__(self, ctx: ReviewContext) -> StageResult: ...


# ------------- Stages (教学占位 + 可运行启发式) -------------


def security_stage(ctx: ReviewContext) -> StageResult:
    findings: list[Finding] = []
    secret_markers = ("AKIA", "BEGIN PRIVATE KEY", "ghp_", "xoxb-")
    for ch in ctx.chunks:
        for i, line in enumerate(ch.patch.splitlines(), start=1):
            for m in secret_markers:
                if m in line and line.startswith("+") and not line.startswith("+++"):
                    findings.append(
                        Finding(
                            stage="security",
                            rule_id="sec.secret_marker",
                            severity="high",
                            path=ch.path,
                            line=i,
                            message=f"疑似敏感信息片段:{m}",
                            blocking=True,
                        )
                    )
    return StageResult(stage="security", findings=findings, metrics={"scanned_chunks": len(ctx.chunks)})


def architecture_stage(ctx: ReviewContext) -> StageResult:
    # 第25讲占位:检查是否违反分层导入(启发式)
    bad = []
    for ch in ctx.chunks:
        if ch.path.endswith(".py") and "import app.presentation" in ch.patch and "app/domain" in ch.path.replace("\\", "/"):
            bad.append(
                Finding(
                    stage="architecture",
                    rule_id="arch.layering",
                    severity="medium",
                    path=ch.path,
                    line=None,
                    message="领域层文件变更中出现对 presentation 的导入,疑似分层违背(启发式)。",
                )
            )
    return StageResult(stage="architecture", findings=bad, metrics={})


def performance_stage(ctx: ReviewContext) -> StageResult:
    # 简化版:检测 async + time.sleep(与第27讲同思路,patch 文本级)
    findings: list[Finding] = []
    for ch in ctx.chunks:
        if not ch.path.endswith(".py"):
            continue
        text = ch.patch
        if "async def" in text and "time.sleep(" in text:
            findings.append(
                Finding(
                    stage="performance",
                    rule_id="perf.async_sleep",
                    severity="high",
                    path=ch.path,
                    line=None,
                    message="patch 中同时出现 async def 与 time.sleep(,疑似阻塞事件循环。",
                    blocking=False,
                )
            )
    return StageResult(stage="performance", findings=findings, metrics={})


def quality_stage(ctx: ReviewContext) -> StageResult:
    # 简化:文件过大提示(教学)
    findings: list[Finding] = []
    for ch in ctx.chunks:
        added = sum(1 for ln in ch.patch.splitlines() if ln.startswith("+") and not ln.startswith("+++"))
        if added > 300:
            findings.append(
                Finding(
                    stage="quality",
                    rule_id="quality.large_change",
                    severity="low",
                    path=ch.path,
                    line=None,
                    message=f"单文件新增行数较多(约 {added}),建议拆分 PR 或补充测试说明。",
                )
            )
    return StageResult(stage="quality", findings=findings, metrics={})


def agents_compliance_stage(ctx: ReviewContext) -> StageResult:
    # 模块三占位:若变更触及 AGENTS.md,提示检查规范更新
    findings: list[Finding] = []
    for ch in ctx.chunks:
        if ch.path.lower().endswith("agents.md"):
            findings.append(
                Finding(
                    stage="agents",
                    rule_id="agents.touch",
                    severity="low",
                    path=ch.path,
                    line=None,
                    message="AGENTS.md 发生变更:请确认规范解析与索引是否需要重建(模块三流程)。",
                )
            )
    return StageResult(stage="agents", findings=findings, metrics={})


# ------------- Pipeline -------------


class ReviewPipeline:
    def __init__(self, stages: list[StageFn] | None = None) -> None:
        self._stages = stages or [
            security_stage,
            architecture_stage,
            performance_stage,
            quality_stage,
            agents_compliance_stage,
        ]

    def run(self, ctx: ReviewContext) -> ReviewReport:
        t0 = time.perf_counter()
        all_findings: list[Finding] = []
        timings: dict[str, int] = {}
        for st in self._stages:
            s0 = time.perf_counter()
            try:
                res = st(ctx)
            except Exception as exc:  # noqa: BLE001
                res = StageResult(
                    stage=getattr(st, "__name__", "stage"),
                    findings=[
                        Finding(
                            stage="platform",
                            rule_id="stage.error",
                            severity="medium",
                            path="-",
                            line=None,
                            message=f"阶段执行失败(已降级):{exc}",
                        )
                    ],
                    metrics={"error": True},
                )
            ms = int((time.perf_counter() - s0) * 1000)
            timings[res.stage] = ms
            all_findings.extend(res.findings)

        all_findings = dedupe_findings(all_findings)
        blocking = any(f.blocking for f in all_findings)
        summary = GitHubCommentFormatter().render_summary(
            ctx, all_findings, blocking=blocking, timings_ms=timings, total_ms=int((time.perf_counter() - t0) * 1000)
        )
        return ReviewReport(
            report_id=str(uuid.uuid4()),
            repo=f"{ctx.owner}/{ctx.repo}",
            pr_number=ctx.pr_number,
            head_sha=ctx.head_sha,
            blocking=blocking,
            summary_md=summary,
            findings=all_findings,
            timings_ms=timings,
        )


def dedupe_findings(findings: list[Finding]) -> list[Finding]:
    seen: set[str] = set()
    out: list[Finding] = []
    for f in findings:
        key = f"{f.rule_id}|{f.path}|{f.line}|{hashlib.sha1(f.message.encode()).hexdigest()}"
        if key in seen:
            continue
        seen.add(key)
        out.append(f)
    return out


class GitHubCommentFormatter:
    def badge(self, severity: str) -> str:
        return {"high": "🔴 HIGH", "medium": "🟡 MED", "low": "🔵 LOW"}.get(severity.lower(), "⚪ INFO")

    def render_summary(
        self,
        ctx: ReviewContext,
        findings: list[Finding],
        *,
        blocking: bool,
        timings_ms: dict[str, int],
        total_ms: int,
    ) -> str:
        lines: list[str] = []
        tick = chr(96)
        lines.append("## CodeSentinel 审核报告")
        lines.append("")
        repo_ref = f"{tick}{ctx.owner}/{ctx.repo}{tick}"
        head_ref = f"{tick}{ctx.head_sha[:7]}{tick}"
        lines.append(f"- **仓库**:{repo_ref}  PR **#{ctx.pr_number}**")
        lines.append(f"- **HEAD**:{head_ref}")
        lines.append(f"- **结论**:{'**阻塞**(存在 high blocking 项)' if blocking else '**非阻塞**(请注意 medium/low 提示)'}")
        lines.append(f"- **总耗时**:{total_ms} ms")
        lines.append("")
        lines.append("### Findings")
        if not findings:
            lines.append("- ✅ 未发现明显问题(教学管线,规则有限)")
        else:
            for f in findings:
                loc = f"L{f.line}" if f.line is not None else "文件级"
                b = self.badge(f.severity)
                lines.append(
                    f"- {b} {tick}{f.stage}{tick} / {tick}{f.rule_id}{tick} / {tick}{f.path}{tick} @ {loc}: {f.message}"
                )
        lines.append("")
        lines.append("### Stage 耗时(毫秒)")
        lines.append("")
        fence = chr(96) * 3
        lines.append(fence + "json")
        lines.append(json.dumps(timings_ms, ensure_ascii=False, indent=2, sort_keys=True))
        lines.append(fence)
        return "\n".join(lines)

    def render_inline(self, findings: list[Finding], *, max_items: int = 5) -> list[dict[str, Any]]:
        comments: list[dict[str, Any]] = []
        for f in findings:
            if f.line is None:
                continue
            if len(comments) >= max_items:
                break
            comments.append(
                {
                    "path": f.path,
                    "side": "RIGHT",
                    "line": f.line,
                    "body": f"{self.badge(f.severity)} {chr(96)}{f.rule_id}{chr(96)}: {f.message}",
                }
            )
        return comments


# ------------- Diff parsing (simplified) -------------


def parse_github_files_json(files: list[dict[str, Any]]) -> list[ChangeChunk]:
    chunks: list[ChangeChunk] = []
    for f in files:
        filename = str(f.get("filename", ""))
        patch = str(f.get("patch", "")) or ""
        if not patch:
            continue
        chunks.append(ChangeChunk(path=filename, patch=patch))
    return chunks


# ------------- GitHub client + Webhook (sync demo) -------------


class GitHubAPI:
    def __init__(self, token: str) -> None:
        self._c = httpx.Client(
            base_url="https://api.github.com",
            headers={
                "Authorization": f"Bearer {token}",
                "Accept": "application/vnd.github+json",
                "X-GitHub-Api-Version": "2022-11-28",
            },
            timeout=30.0,
        )

    def close(self) -> None:
        self._c.close()

    def get_pr_files(self, owner: str, repo: str, number: int) -> list[dict[str, Any]]:
        r = self._c.get(f"/repos/{owner}/{repo}/pulls/{number}/files")
        r.raise_for_status()
        return r.json()

    def post_review(self, owner: str, repo: str, number: int, payload: dict[str, Any]) -> None:
        r = self._c.post(f"/repos/{owner}/{repo}/pulls/{number}/reviews", json=payload)
        r.raise_for_status()

    def post_status(self, owner: str, repo: str, sha: str, payload: dict[str, Any]) -> None:
        r = self._c.post(f"/repos/{owner}/{repo}/statuses/{sha}", json=payload)
        r.raise_for_status()


def build_app() -> FastAPI:
    app = FastAPI(title="CodeSentinel Module4 Pipeline", version="1.0.0")
    token = os.environ.get("GITHUB_TOKEN", "")
    secret = os.environ.get("GITHUB_WEBHOOK_SECRET", "devsecret")
    gh = GitHubAPI(token) if token else None
    pipeline = ReviewPipeline()

    @app.on_event("shutdown")
    def _close() -> None:
        if gh:
            gh.close()

    @app.get("/healthz")
    def healthz() -> dict[str, str]:
        return {"status": "ok"}

    @app.post("/demo/run_pipeline")
    def demo_run_pipeline(body: ReviewContext) -> dict[str, Any]:
        report = pipeline.run(body)
        return {
            "report_id": report.report_id,
            "repo": report.repo,
            "pr_number": report.pr_number,
            "head_sha": report.head_sha,
            "blocking": report.blocking,
            "summary_md": report.summary_md,
            "findings": [dataclasses.asdict(f) for f in report.findings],
            "timings_ms": report.timings_ms,
            "pipeline_version": report.pipeline_version,
        }

    @app.post("/hooks/github")
    async def github_hook(
        request: Request,
        x_hub_signature_256: str | None = Header(default=None),
    ) -> dict[str, Any]:
        raw = await request.body()
        verify_github_signature(secret, raw, x_hub_signature_256)
        payload = json.loads(raw.decode("utf-8"))
        if request.headers.get("X-GitHub-Event") != "pull_request":
            return {"ignored": True}

        action = payload.get("action")
        if action not in {"opened", "synchronize", "reopened"}:
            return {"ignored": True}

        pr = payload["pull_request"]
        repo = pr["base"]["repo"]["full_name"]
        owner, name = repo.split("/", 1)
        number = int(pr["number"])
        head_sha = str(pr["head"]["sha"])

        if gh is None:
            return {"ok": False, "error": "GITHUB_TOKEN missing (set for real writeback)"}

        gh.post_status(
            owner,
            name,
            head_sha,
            {
                "state": "pending",
                "context": "codesentinel/pipeline",
                "description": "CodeSentinel 审核运行中",
            },
        )

        files = gh.get_pr_files(owner, name, number)
        chunks = parse_github_files_json(files)
        ctx = ReviewContext(owner=owner, repo=name, pr_number=number, head_sha=head_sha, chunks=chunks)
        report = pipeline.run(ctx)
        fmt = GitHubCommentFormatter()

        gh.post_review(
            owner,
            name,
            number,
            {
                "commit_id": head_sha,
                "body": report.summary_md,
                "event": "REQUEST_CHANGES" if report.blocking else "COMMENT",
                "comments": fmt.render_inline(report.findings),
            },
        )

        gh.post_status(
            owner,
            name,
            head_sha,
            {
                "state": "failure" if report.blocking else "success",
                "context": "codesentinel/pipeline",
                "description": "存在阻塞项" if report.blocking else "审核通过(规则范围内)",
            },
        )

        return {"ok": True, "report_id": report.report_id, "blocking": report.blocking}

    return app


def verify_github_signature(secret: str, body: bytes, signature_header: str | None) -> None:
    import hashlib
    import hmac

    if not signature_header or not signature_header.startswith("sha256="):
        raise HTTPException(status_code=401, detail="missing signature")
    mac = hmac.new(secret.encode("utf-8"), msg=body, digestmod=hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()
    if not hmac.compare_digest(expected, signature_header):
        raise HTTPException(status_code=401, detail="bad signature")


app = build_app()

说明/demo/run_pipeline 返回显式 dict,并对 Finding 使用 dataclasses.asdict;生产可换成 Pydantic 响应模型以获得更严格的 schema。


集成测试:tests/test_module4_webhook_mock.py

from __future__ import annotations

import hashlib
import hmac
import json

import pytest
from fastapi.testclient import TestClient

from pipeline_app import build_app, verify_github_signature


def sign(body: bytes, secret: str) -> str:
    mac = hmac.new(secret.encode("utf-8"), msg=body, digestmod=hashlib.sha256)
    return "sha256=" + mac.hexdigest()


def test_verify_signature_ok() -> None:
    body = b"{\"hello\":true}"
    secret = "s"
    verify_github_signature(secret, body, sign(body, secret))


def test_demo_pipeline_runs() -> None:
    app = build_app()
    client = TestClient(app)
    ctx = {
        "owner": "acme",
        "repo": "demo",
        "pr_number": 1,
        "head_sha": "abc" * 7,
        "chunks": [{"path": "app/x.py", "patch": "+async def f():\n+    time.sleep(1)\n"}],
    }
    r = client.post("/demo/run_pipeline", json=ctx)
    assert r.status_code == 200
    data = r.json()
    assert data["blocking"] is False
    assert any(f["rule_id"] == "perf.async_sleep" for f in data["findings"])


def test_webhook_missing_token_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("GITHUB_TOKEN", raising=False)
    monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", "sec")
    app = build_app()
    client = TestClient(app)
    payload = {
        "action": "opened",
        "pull_request": {
            "number": 1,
            "html_url": "http://example.com",
            "head": {"sha": "a" * 40},
            "base": {"repo": {"full_name": "acme/demo"}},
        },
    }
    body = json.dumps(payload).encode("utf-8")
    headers = {
        "X-GitHub-Event": "pull_request",
        "X-Hub-Signature-256": sign(body, "sec"),
    }
    r = client.post("/hooks/github", content=body, headers=headers)
    assert r.status_code == 200
    assert r.json().get("ok") is False

运行:pytest -q(需将 pipeline_app.py 置于 PYTHONPATH 或同目录)。


Docker Compose:docker-compose.module4.yml

services:
  review-service:
    image: python:3.12-slim
    working_dir: /app
    volumes:
      - ./:/app
    command: ["bash", "-lc", "pip install -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
    ports:
      - "8000:8000"
    environment:
      - PYTHONUNBUFFERED=1

  sentinel-pipeline:
    image: python:3.12-slim
    working_dir: /work
    volumes:
      - ./module4:/work
    command: ["bash", "-lc", "pip install fastapi uvicorn httpx pydantic && uvicorn pipeline_app:app --host 0.0.0.0 --port 9000"]
    ports:
      - "9000:9000"
    environment:
      - GITHUB_WEBHOOK_SECRET=devsecret
      - GITHUB_TOKEN=

教学说明:review-service 指向 codesentinel-clean-lab 时,把 volumescommand 调整为该项目的启动方式;sentinel-pipeline 假设你把 pipeline_app.py 放在 module4/ 目录。


curl:模拟 /demo/run_pipeline(无需 GitHub)

curl -sS -X POST "http://127.0.0.1:9000/demo/run_pipeline" ^
  -H "Content-Type: application/json" ^
  -d "{\"owner\":\"acme\",\"repo\":\"demo\",\"pr_number\":42,\"head_sha\":\"abcdef0\",\"chunks\":[{\"path\":\"bad.py\",\"patch\":\"+AKIAEXAMPLE\\n\"}]}"

(Linux/macOS 请将 ^ 换行改为 \。)

期望响应中出现 "blocking": truesec.secret_marker。若返回 422,请检查 JSON 转义与 Content-Type。Windows PowerShell 可用 curl.exe 避免别名干扰。


curl:模拟 webhook(需要正确签名)

python - <<'PY'
import hashlib,hmac,json,os
import subprocess,sys
body = json.dumps({"action":"opened","pull_request":{
  "number":1,"html_url":"http://x",
  "head":{"sha":"a"*40},
  "base":{"repo":{"full_name":"acme/demo"}}
}}).encode()
sec=b"devsecret"
sig="sha256="+hmac.new(sec,body,hashlib.sha256).hexdigest()
open("payload.json","wb").write(body)
print(sig)
PY

curl -sS -X POST "http://127.0.0.1:9000/hooks/github" ^
  -H "Content-Type: application/json" ^
  -H "X-GitHub-Event: pull_request" ^
  -H "X-Hub-Signature-256: <paste_sig>" ^
  --data-binary @payload.json

生产环境实战:模块四交付 checklist

  1. Webhook 异步化 + 队列 + 分布式锁(按 head_sha)。
  2. GitHubCommentFormatter 增加长度限制与密钥 redaction。
  3. SecurityStage 接入真实扫描器与允许列表。
  4. PerformanceStage 直接 import 第 27 讲检测器对变更文件 ast.parse
  5. QualityStage 调用第 28 讲 QualityScorer 并输出 Checks 子 context。
  6. AgentsComplianceStage 调用模块三标准服务 HTTP API。
  7. 双写 codesentinel-clean-lab review 记录用于审计。
  8. 指标:Prometheus histogram 记录 stage_seconds
  9. SLO:95% PR 审核 < 2 分钟(视规则而定)。
  10. 机密:token 与 secret 走 vault。
  11. 容量规划:为上班集中推送预留队列峰值;设置最大排队时间与超时 error 状态,避免永远 pending。
  12. 数据库事务:内部 review 记录写入应可补偿或事务化,避免半成功。
  13. 对象存储:超大 diff 用指针传递,降低 worker 内存峰值。
  14. Feature flag:按仓库/团队/路径渐进启用 stage,避免一次性压垮平台。
  15. SRE 演练:模拟 GitHub API 不可用时的降级(延迟写回、只存内部)。
  16. 成本:若接入 LLM,按 PR 汇总 token 费用,防止预算失控。
  17. 多区域:避免跨区反复拉取大 diff。
  18. 变更窗口:维护期切换 dry-run,避免错误评论污染 PR。
  19. 支持 playbook:重跑、解释 blocking、临时豁免的标准流程。
  20. 品牌与信任:评论页脚链接内部文档,声明生成来源与限制。
  21. 可迁移性:保持编排器与 webhook 薄层分离,便于迁移 Serverless。
  22. 静态资源:报告图片走受控 CDN。
  23. 合规留存:金融客户场景以内部聚合记录为准,平台评论可被编辑删除。
  24. 工程师体验:先试点仓库收集「评论长度/语气/密度」反馈再全量推广。
  25. 架构评审:用本讲架构图讨论单点、凭据、数据流与瓶颈。
  26. 与「AI 架构师」关系:平台扩展执行与证据,不替代人类判断。
  27. 知识库:误报案例沉淀为条目,供后续 LLM 解释引用。
  28. Auto-merge:状态检查命名稳定,失败必须阻止合并。
  29. 供应链:pipeline 自身依赖纳入漏洞扫描。
  30. 退出策略:平台故障时仍有合入路径,避免工具绑架业务。

module4/ 参考实现的关系

为验证文中代码可运行,仓库内提供 AI架构师与代码审核实战/module4/pipeline_app.pytests/requirements.txt,可由 pytest 回放集成测试。你也可只保留 Markdown,将 module4/ 视为可选实验目录。建议命令:cd module4 && pip install -r requirements.txt && python -m pytest tests -q。若遇到 FastAPI on_event 弃用告警,可忽略或在后续迭代改为 lifespan 写法。


本讲小结(Mermaid Mindmap)

mindmap
  root((第30讲\n模块实战))
    输入
      Webhook
      PR files patch
    编排
      ReviewPipeline
      stages 可插拔
    输出
      ReviewReport
      Markdown 评论
      commit status
    交付
      pytest 集成
      docker compose
      curl 演示
    演进
      异步队列
      真实规则接入

复盘提纲(建议在团队回顾会上使用)

下面问题适合作为三十分钟会议议程的核心提纲,可按需增删。

  1. 本周触发 webhook 次数、失败次数、平均耗时、Top3 失败原因分别是什么?
  2. 哪些规则产生最多 findings?其中被标记误报的比例?
  3. 有多少 PR 因为 blocking 规则被拦截?拦截后平均多久修复?
  4. 平台侧评论长度分布如何?是否需要进一步压缩行内评论?
  5. 与人工评审结论不一致的案例有多少?不一致原因归类?

把上述问题回答沉淀为「月度治理简报」,CodeSentinel 才会从工具升级为管理抓手。

与真实企业落地的差距清单(诚实版)

  • 未实现真实队列与 worker 水平扩展。
  • 未实现 GitHub App 安装级凭据轮换。
  • 未实现精细化行号映射(仅教学级定位)。
  • 未实现 LLM 调用的配额、缓存与审计。
  • 未实现与企业 IAM 的组织/仓库授权模型。

清单应挂在项目看板「Epic:生产化」,逐项排期消化。

思考题

  1. 你会如何把 stage 从串行改为并行,同时保证聚合顺序与错误隔离?

  2. 当 PR 修改了 200 个文件,如何在不牺牲安全的前提下限制审核耗时?

  3. REQUEST_CHANGES 自动触发是否符合你们组织文化?如果不符合,如何用 status failure 替代?

  4. 如果某个 stage 的结果需要人工复核后才能决定是否 blocking,你会如何改造状态机与评论模板?

  5. 如何把 ReviewReport 以结构化形式进入数据仓库,以支持「规则有效率」与「缺陷逃逸率」的量化分析?


下一讲预告(模块五建议)

进入「多仓库治理与策略中心」:把规则集、阈值、凭据与组织映射抽象为配置服务,让 CodeSentinel 支持企业级规模与多团队差异化策略;并与统一身份认证、审计、账单核算联动。

FAQ(高频追问)

问:为什么不让 LLM 直接读整个 PR 然后输出评论?
答:成本高、延迟高、定位差且难以审计。正确做法是先确定性规则收窄候选,再让模型做解释与建议(可选)。

问:pipeline 应该先安全还是先质量?
答:编排顺序对结果影响小,对「早失败」影响大。通常安全与密钥扫描应靠前,快速失败节省后续开销。

问:如何处理 monorepo 多包?
答:chunks 按路径前缀路由到不同规则配置;或在 stage 内读取 codesentinel.yaml 选择启用规则集。

问:审核是否会泄露代码到第三方?
答:取决于你是否把 diff 发给外部模型。默认应在数据分类政策内处理;涉密仓库必须私有化部署模型或只做本地规则。

问:如何处理 rebase 之后评论错位?
答:GitHub 会尝试映射;平台不可靠时,应在新的 head sha 上重跑并发新摘要,旧评论标记过时。

问:性能测试要放进 pipeline 吗?
答:重负载压测不适合每次 PR;可做「变更触发轻量基准」或夜间任务。PR 阶段以静态性能规则为主。

问:如何让研发愿意看评论?
答:控制数量、提高信噪比、语气专业、给出可执行修复建议,并把误报反馈闭环做快。

问:模块四交付物到底是什么?
答:最小交付是可运行的 ReviewPipeline + 平台写回路径 + 基本测试;完整交付还包括异步化、凭据体系、看板与治理流程。

问:为什么示例里仍用 httpx.Client 同步调用?
答:教学代码强调「调用顺序正确」优先于异步极致;生产请改为 AsyncClient 或把网络 I/O 放到线程池,并与第 29 讲的异步队列组合。

术语对照(中英)

本表用于新人 onboarding 速查,不求全面,只求把最容易混淆的概念一次对齐。

  • Pull Request(PR):拉取请求;GitLab 称 Merge Request(MR)。
  • Webhook:事件回调。
  • Check / Status:提交检查状态。
  • Stage:审核阶段。
  • Finding:一条具体问题记录。
  • Blocking:是否阻塞合并策略(由平台与规则共同决定)。
  • Orchestrator:编排器,本讲中的 ReviewPipeline

与课程其它模块的映射关系(备忘)

  • 模块二:审核聚合与 finding 接口(codesentinel-clean-lab)。
  • 模块三:AGENTS.md 管理与合规(本讲 AgentsComplianceStage 占位)。
  • 模块四第 25~29 讲:分别补齐架构、安全、性能、质量与 Git 集成;第 30 讲负责串联。

把这张映射表打印贴在笔记本扉页,复习时能快速定位知识坐标。

与「CodeSentinel」产品叙事的一句话总结

CodeSentinel 不是又一个 linter,而是把架构原则、安全底线、性能卫生、质量趋势与规范合规,封装成可重复执行的合入前治理链路;本讲管线是它的「脊柱」。你可以用这句话向非技术干系人解释为什么要投入工程化,而不是买一张「AI 代码助手」许可证就期待奇迹。