第 18 章:GitHub、VCS 与代码托管闭环
18.1 为什么 GitHub 不放进通用 VCS Adapter
源码给出了明确理由:
- GitHub 使用 App Installation 和短期 Installation Token;
- Forgejo/Gitea/GitLab 使用用户提供的长期 Access Token;
- GitHub CI 是 Check Suite;
- Forgejo/Gitea 是 Commit Status;
- GitLab 是 Pipeline;
- GitHub Installation Webhook 有独立生命周期。
所以架构是:
- handler/github.go:GitHub 专用路径;
- integrations/vcs:Token-based Provider Adapter;
- handler/vcs_webhook.go:通用镜像与 Issue 联动。
它们在存储层保留不同表,在 Issue PR 展示与关闭聚合上再统一。
18.2 GitHub App 安装流程
GitHub 集成依赖 App 配置:
- App ID;
- Private Key;
- Webhook Secret;
- Public Callback/URL。
安装大致分两步:
- 用户在 GitHub 完成 App Installation;
- Multica Callback 把 Installation 绑定到当前 Workspace。
Webhook 可能先于 Callback 到达。系统用 github_pending_installation 保存暂时无法映射 Workspace 的 Installation 元数据,Callback 后再完成绑定。
18.3 一个 Installation 可绑定多个 Workspace
查询 github.sql 明确允许按 GitHub Installation ID 找到多条 Workspace Binding。
收到 PR Webhook 时:
- 先找全部绑定;
- 对每个 Workspace 独立镜像;
- 使用各自 Issue Prefix 与 Settings;
- 只链接各自 Workspace 中的 Issue;
- 分别广播 Event。
Repo Scope 来自 GitHub App 的 Installation 权限,不等同于 Multica Workspace Scope。
18.4 GitHub App JWT
Server 用 Private Key 签 RS256 App JWT,再向 GitHub 读取 Installation/Account 信息。
实现考虑:
- JWT 最长有效窗口;
iat向前回拨一分钟吸收时钟偏差;- 私钥解析失败不在日志打印 Key;
- Installation 请求使用 GitHub Vendor Accept Header;
- 连接失败与未知 Installation 区分。
App JWT 不是传给 Daemon 的 Agent Credential。
18.5 GitHub Webhook 入口
POST /api/webhooks/github:
- 要求 Server 配置 Webhook Secret;
- 读取 Raw Body;
- 常量时间验证
X-Hub-Signature-256; - 按
X-GitHub-Event分派; - 处理 Installation、Pull Request、Check Suite;
- 未建模事件仍 ACK,避免 GitHub 把 Endpoint 标成故障。
未配置 Secret 时返回 Service Unavailable,而不是把所有请求当合法。
18.6 PR 镜像
GitHub PR Row 保存:
- Installation/Workspace、Repo、Number;
- Title、Body/URL;
- State、Draft、Mergeable State;
- Branch、Head SHA;
- Author;
- Additions/Deletions/Changed Files;
- Created/Updated/Merged/Closed 时间。
Webhook Upsert 不是无条件覆盖。例如 GitHub 某些事件不重新计算 Mergeability,derivePRMergeableState 会决定何时保留旧值,避免低信息事件清空已有信息。
18.7 Check Suite 可能先于 PR
Webhook 不保证跨事件类型顺序。Check Suite 到达时,本地 PR 可能还没镜像。
GitHub 路径提供 github_pending_check_suite:
- 找不到 PR 时暂存 Suite;
- 同 Suite 的新事件按更新时间覆盖旧暂存;
- PR Webhook 到达并 Upsert 后 Drain;
- 通过正常 Check Suite Upsert 重放;
- 删除 Pending Row。
这比丢掉事件后等待下一次 CI 更可靠。
18.8 Check 状态聚合
每个 Check Suite 独立存储并按更新时间单调 Upsert。Issue 读取 PR 时聚合:
- checks_total;
- checks_passed;
- checks_failed;
- checks_pending。
Queued/In-progress 算 Pending;Neutral/Skipped 等不阻塞结果可按 Passed 归一。聚合发生在查询层,PR Row 不保存一份易漂移的计数。
18.9 通用 VCS Provider 接口
Kind;EventKind(headers);VerifySignature;ParsePullRequest;ParseCIStatus;ValidateToken。
支持的 Kind:
- forgejo;
- gitea;
- gitlab。
Handler 只消费规范化的 PR/CI Event,不分支理解各 Provider Payload。
18.10 Forgejo 与 Gitea
forgejo.go 用一个 Adapter 服务两者,因为它们当前相关 Wire 形态相同:
/api/v1/user校验 Token;Authorization: token ...;X-Gitea-Event;X-Gitea-SignatureHMAC-SHA256;- Pull Request / Status Payload。
Signature 通常是裸 Hex;实现也容忍 sha256= 前缀。空 Secret 永远拒绝。
18.11 GitLab
gitlab.go 处理差异:
/api/v4/user;PRIVATE-TOKEN;X-Gitlab-Event;X-Gitlab-Token常量时间比较;- Merge Request → PullRequestEvent;
- Pipeline → CIStatusEvent;
- GitLab 时间格式先规范化为 RFC3339。
时间规范化必须在 Adapter 层完成,否则 Shared Handler 回退到 Ingestion Time,会破坏乱序保护。
18.12 规范化状态
PR 状态统一为:
- open;
- draft;
- closed;
- merged。
CI 状态统一为:
- passed;
- failed;
- pending。
未知 CI 状态保守地映射 Pending,避免把尚未理解的新状态当成功。
GitLab Pipeline 当前用稳定的合成 Context 表示每个 Commit 的单一状态;Merge-train Synthetic SHA 等高级场景是这层简化的已知限制。
18.13 建立 VCS Connection
handler/vcs.go 的连接流程:
- 检查 Deployment 是否开放 Self-host VCS;
- 检查 Secret Box 配置;
- 校验 Provider Kind;
- 规范化绝对 HTTP(S) Instance URL;
- 在线验证 Access Token;
- 生成 32 字节随机 Webhook Secret;
- 分别加密 Access Token 与 Webhook Secret;
- Upsert Workspace + Provider + Instance Connection;
- 返回 Connection 与一次性明文 Secret。
以后列表不再返回 Secret;需要时只能 Rotate。
18.14 为什么 Managed Cloud 可以关闭通用 VCS
源码将两种状态分开:
available:产品/部署是否提供此能力;configured:Operator 是否配置加密 Key。
Managed Cloud 可直接不开放用户任意 Self-host Instance;UI 隐藏整个区块。Self-host 开启但漏配 Key,则返回配置错误帮助 Operator 修复。
18.15 VCS Webhook 路由
路径为:
/api/webhooks/vcs/{connectionId}
Connection ID 决定:
- Workspace;
- Provider Kind;
- 解密哪个 Secret。
Handler:
- 最多读取 10 MiB Body;
- 查 Connection;
- 解密 Webhook Secret;
- 调 Provider 验签;
- 分类 PR/CI;
- 解析成统一 Event;
- 镜像并广播;
- 未建模事件返回 Accepted。
未知 Connection 与不可用部署不暴露内部配置细节。
18.16 PR 的单调更新
Webhook 可能乱序重投。通用 vcs.sql 的 PR Upsert 对每个字段使用:
只有新事件的 pr_updated_at 不早于持久 Row,才覆盖。
即使事件过期,Query 仍返回现有 PR ID,便于 Link 操作;Handler 随后比较 Event Timestamp,严格更旧就停止,不重写 Link Metadata 或重复 Publish。
18.17 CI 的单调更新
Commit Status 也按 Provider 自己的 updated_at 保护:
- Forgejo/Gitea 优先 Status Updated At;
- GitLab 优先 Pipeline Finished At;
- 无时间才使用 Ingestion Time;
- 同 SHA + Context 的旧状态不能覆盖新状态。
若一条旧 Pending 在新 Passed 后重放,UI 不应倒退。
18.18 Issue 标识自动链接
PR Title、Body、Branch 中的 PREFIX-NUMBER 会被提取,再按当前 Workspace Prefix 查 Issue。
链接分两类:
- Qualifying Link:Title/Branch 引用,或 Body 中 Closing Keyword;
- Reference-only Link:只在 Body 普通提及。
Reference-only 可保留历史关系,但不作为主要工作 PR,也不参与自动关闭聚合。
18.19 Closing Keyword
系统识别 GitHub 风格词形,例如 Close/Fix/Resolve 的时态变化,且标识必须紧随其后。
只扫描 Title/Body 的 Closing Intent,不把 Branch 名当“请求关闭”。Branch 引用可建立 Qualifying Link,但不会因为叫 fix/MUL-1 就自动完成 Issue。
18.20 Close Intent 必须冻结
PR 在 Open/Edit 阶段还可改变 Description,因此 Close Intent 可更新;一旦收到 Terminal Merge/Close Event:
- 持久化最终 Close Intent;
- 后续乱序的 Open/Edit/Synchronize 不得改写;
- 自动推进依据持久 Link State,而不是“本次 Webhook 是否带关键词”。
否则一个合并后晚到的旧事件可能把 Closing PR 变成普通 Reference。
18.21 跨 Provider 关闭聚合
同一个 Issue 可以同时链接 GitHub PR 和 GitLab/Forgejo PR。完成判断必须跨两组表:
- Open Count = 0;
- 至少一个 Merged PR 带 Close Intent;
- Reference-only 不参与;
- Issue 还不是 Done/Cancelled。
vcs.sql 联合 GitHub 与通用 VCS 数据。任一 Provider 的 Terminal Event 都会重新计算全部 PR,避免“GitLab 合并时看不见仍打开的 GitHub PR”。
18.22 自动推进到 Done
满足关闭聚合时,advanceIssueToDone 走共享 Issue 更新逻辑:
- 改状态为 done;
- 写 Activity/Source;
- 发布 Issue Event;
- 触发 Parent/Child 相关处理;
- 同步 Autopilot Run 等派生状态。
Integration 不直接执行一条裸 SQL,否则会绕过业务副作用。
18.23 Head SHA 与 CI
PR 的 head_sha 连接 Commit Status:
- 新 PR/Synchronize 更新 Head SHA;
- CI Event 按 SHA Upsert;
- 查询找到所有 Head SHA 匹配的 PR/Issue;
- 发布 Pull Request Updated;
- UI 重算 Checks。
GitHub 与通用 VCS 的 Head SHA 去重/查询也需要跨 Provider 协调,不能假设 Issue 只连一种 Git。
18.24 安全边界
- GitHub Webhook 和 Forgejo HMAC 使用常量时间比较;
- GitLab Token 比较也用常量时间;
- 空 Secret 一律拒绝;
- Access Token 与 Webhook Secret 加密存储;
- 明文 Secret 只在创建/旋转响应返回一次;
- Secret 不出现在 List Response;
- 未配置 GitHub Secret 时拒绝全部 Webhook;
- Workspace 来自 Connection/Installation,不信任请求 Header;
- Instance Token 验证错误不回显上游敏感 Body;
- 删除 Connection 同时清理镜像和链接数据。
18.25 GitHub 与通用 VCS 的差异表
| 维度 | GitHub | Forgejo/Gitea | GitLab |
|---|---|---|---|
| 连接身份 | App Installation | Access Token | Access Token |
| Token 校验 | App API/JWT | api/v1/user | api/v4/user |
| Webhook 鉴权 | HMAC sha256= | HMAC Hex | Shared Header Token |
| PR 名称 | Pull Request | Pull Request | Merge Request |
| CI | Check Suite | Commit Status | Pipeline |
| 未到 PR 的 CI | Pending Check Suite | 依 SHA 后续查询 | 依 SHA 后续查询 |
| Workspace 绑定 | 一个 Installation 可多 Workspace | Connection 属于一个 Workspace | Connection 属于一个 Workspace |
18.26 增加一个通用 Provider
- 定义持久 Kind;
- 实现 Header Event Classifier;
- 实现严格验签;
- 实现 Token Validation;
- 规范化 PR 状态、字段、时间;
- 规范化 CI 状态与真实更新时间;
- 注册 Provider;
- 写 Payload/Signature/Token 测试;
- 验证 Issue Identifier 与 Close Intent;
- 验证乱序事件不会回退;
- 说明 Provider 特有的 CI/SHA 局限;
- 检查设置 UI 与 Secret 一次性展示。
如果 Provider 采用 Installation/OAuth、Review/CI 语义差异巨大,应该像 GitHub 一样保留特化路径,而不是把通用接口扩成一堆可选方法。
18.27 本章结论
代码托管闭环不只是展示一个 PR 链接。Multica 做了:
- 安全连接外部 Provider;
- 镜像 PR 与 CI;
- 抵抗乱序和重放;
- 从 Issue Identifier 建立关系;
- 冻结 Closing Intent;
- 跨 Provider 聚合开放 PR;
- 在真正满足条件时推进 Issue。
专用 GitHub 路径与通用 VCS Adapter 的分界,体现了“统一业务语义,不强行统一认证和协议”。