diff --git a/.gitignore b/.gitignore
index af7995c..da5bd00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ out/
.DS_Store
Thumbs.db
*visual-check*
+.archify/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 630d867..9149aad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@
## [Unreleased]
+### Fixed
+
+- **CI 状态与 Branches PR 徽标注入取数主机策略**:仅 github.com 与已配置 `github-enterprise.uri` 的实例主机发送取数请求;其余「GitHub 形态」远程(origin 指向任意第三方主机)视同非 GitHub(零徽标、零请求),杜绝把 GitHub/GHE token 发往任意主机的凭证外发面。
+
+### Added
+
+- **Branches 视图远程分支行右侧显示关联 GitHub Pull Request 状态徽标**(`#138 Merged` 形态 + 云图标按状态着色:Merged 紫 / Closed 红 / Open 绿 / Draft 弱化;悬停见 PR 号、标题与经反 SSRF 主机白名单校验的 Open on GitHub 链接)。分支 → PR 三级匹配(fork 全名 → 同仓 headRefName → 唯一兜底,歧义宁缺勿错);复用 CI 状态的 GitHub 认证与 remote/provider 单一事实源(`getGitHubContext()`),GraphQL 按 UPDATED_AT 倒序取最近 100 条全态 PR,TTL 60s 缓存 + 同实例引用相等守卫(无刷新循环、首轮无徽标先行不阻塞建树);未登录 / 非 GitHub 远程 / 配置关闭 → 零徽标零请求静默休眠。新增配置 `hyperGit.branches.pr.enabled`(默认开启);engine 纯逻辑 `engine/pr/` 四模块 + 全量单测。详见 [功能文档](./docs/features/branches-pr-status.md)。
+
### Build
- **开发依赖批量升级**(dependabot):`@vscode/vsce` 3.9.2 → **4.0.0**(#131)、`vitest` 4.1.10 → **5.0.1**(#127)、`ovsx` 1.1.1 → 1.2.0(#130)、`typescript-eslint` 8.68.0 → 8.70.0(#129)、`esbuild` 0.28.1 → 0.28.2(#128);两项 major 均把 Node 基线抬至 22(vitest 5 实际要求 ≥ 22.12),与 CI 的 `node-version: 22` 一致。五个 PR 共改 `pnpm-lock.yaml`,顺序合并必连环冲突,故合成单个 PR 只重生一次 lockfile,组合兼容性由 CI 在 `feature/1.x.x` 基线上一次验证。`pnpm-workspace.yaml` 的 `allowBuilds` 三项维持不变——`ovsx@1.2.0` 仍传递依赖 `@vscode/vsce@^3.7.1`,`keytar` 与 `@vscode/vsce-sign`(现 2.0.9 / 2.1.0 双版本)继续在依赖树中,删任一项都会重现 `ERR_PNPM_IGNORED_BUILDS`。
diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md
index 93e0425..47cf9c0 100644
--- a/docs/.agents/knowledge-map.md
+++ b/docs/.agents/knowledge-map.md
@@ -17,6 +17,7 @@
- [文档中心](../docs/README.md) — 文档与调研资产总索引。
- [README(简体中文)](../docs/i18n/zh-CN/README.md) — 根 README 的中文版本(根路径为英文版,i18n 目录沉淀中文)。
- [Log 视图 CI 状态](../docs/features/log-ci-status.md) — 按提交显示 GitHub CI 最终状态(绿勾/红叉 + Tooltip 明细):认证、限流、懒加载、边界与配置。
+- [Branches 视图远程分支 PR 状态徽标](../docs/features/branches-pr-status.md) — 远程分支行右侧显示关联 GitHub PR(`#138 Merged` + 图标按态着色):三级匹配、TTL 缓存与无循环刷新、静默边界与配置。
- [Commit 视图整合](../docs/features/commit-view-consolidation.md) — 移除 CHANGES 视图,其文件操作/changelist 管理/Git 工具栏/未提交角标零回归平移入 Commit 视图。
- [变更文件目录树](../docs/features/file-list-group-by-directory.md) — Commit / Log 文件列表「平铺 ⇄ 按目录分组」切换:host 侧 `buildFileTree` 构树下发、compact 折叠、目录三态。
- [分支前缀分组树](../docs/features/branch-tree-group-by-prefix.md) — Branches 视图 Local/Remote/Tags「平铺 ⇄ 按 `/` 前缀分组」切换:`buildRefTree` 构树、分支感知排序、compact 折叠、隐藏 `origin/HEAD`、按仓库持久化。
@@ -37,8 +38,8 @@
## 架构分层(src/)
> 依赖方向单向:`UI → Adapter → Engine`;`Agent` 以接口注入 `Engine`/`CommitPipeline`,不反向依赖 UI。
-- `engine/` — 纯领域逻辑(零 vscode 依赖,Vitest 可测,15 模块):`model/`、`diff/`、`commit/`、`changelist/`、`log/`、`ref/`、`tree/`、`git-state/`、`scm-mapping/`、`ci/`、`merge/`、`rebase/`、`blame/`、`worktree/`、`agent/`。
-- `adapter/` — 唯一接触 vscode API:`git-api.ts`(getAPI(1))、`GitRepositoryService`(活跃仓库唯一持有者,vscode.git API + execGit 双通道)、`ChangelistRegistry`、`BranchFavorites`、`ShelfService`、`CommitService`、命令注册组、`tree/`、`webview/`、`ci/`、`editor/`。
+- `engine/` — 纯领域逻辑(零 vscode 依赖,Vitest 可测,16 模块):`model/`、`diff/`、`commit/`、`changelist/`、`log/`、`ref/`、`tree/`、`git-state/`、`scm-mapping/`、`ci/`、`pr/`、`merge/`、`rebase/`、`blame/`、`worktree/`、`agent/`。
+- `adapter/` — 唯一接触 vscode API:`git-api.ts`(getAPI(1))、`GitRepositoryService`(活跃仓库唯一持有者,vscode.git API + execGit 双通道)、`ChangelistRegistry`、`BranchFavorites`、`ShelfService`、`CommitService`、命令注册组、`tree/`、`webview/`、`ci/`、`pr/`、`editor/`。
- `agent/` — AI 接缝(当前 5 接口,均 Null 实现,完整逻辑延后至 M5):`ILlmProvider`、`ICommitMessageProvider`、`IPreCommitInspector`、`IChangelistGrouper`、`IConflictResolver`(另有规划中的第 6 接缝 `IChatToolRegistrar`,详见[调研报告](../research/05-ai-agent-seams.md))。
- `shared/protocol.ts` — Webview ↔ Host 消息契约【单一事实源】。
- `infra/` — 日志(OutputChannel)/ 错误处理 / 事件总线 / 配置。
diff --git a/docs/assets/architecture/README.md b/docs/assets/architecture/README.md
index 97b61d2..8795001 100644
--- a/docs/assets/architecture/README.md
+++ b/docs/assets/architecture/README.md
@@ -32,6 +32,7 @@
| [agentic-git-preferences-flow](features/agentic-git-preferences-flow.html) | workflow | [.mmd](../mermaid/features/agentic-git-preferences-flow.mmd) | [agentic-git-preferences.md](../../features/agentic-git-preferences.md) |
| [claude-code-config-flow](features/claude-code-config-flow.html) | workflow | [.mmd](../mermaid/features/claude-code-config-flow.mmd) | [claude-code-config.md](../../features/claude-code-config.md) |
| [log-ci-status-dataflow](features/log-ci-status-dataflow.html) | dataflow | [.mmd](../mermaid/features/log-ci-status-dataflow.mmd) | [log-ci-status.md](../../features/log-ci-status.md) |
+| [branches-pr-status-dataflow](features/branches-pr-status-dataflow.html) | dataflow | [.mmd](../mermaid/features/branches-pr-status-dataflow.mmd) | [branches-pr-status.md](../../features/branches-pr-status.md) |
### research/(调研设计)
| 基名 | archify 类型 | 源 Mermaid | 嵌入文档 |
diff --git a/docs/assets/architecture/features/branches-pr-status-dataflow.dark.svg b/docs/assets/architecture/features/branches-pr-status-dataflow.dark.svg
new file mode 100644
index 0000000..c472287
--- /dev/null
+++ b/docs/assets/architecture/features/branches-pr-status-dataflow.dark.svg
@@ -0,0 +1,655 @@
+
+
\ No newline at end of file
diff --git a/docs/assets/architecture/features/branches-pr-status-dataflow.html b/docs/assets/architecture/features/branches-pr-status-dataflow.html
new file mode 100644
index 0000000..6604f3e
--- /dev/null
+++ b/docs/assets/architecture/features/branches-pr-status-dataflow.html
@@ -0,0 +1,13806 @@
+
+
+
+
+
+
+
+ Branches 视图 PR 徽标数据流
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 图表指南
+ 探索此系统
+
+
+
+
正在检查已编译语义
+
+
+
+
+
+
+
+
+ E 导出T 主题S 风格0 重置+ 放大- 缩小Esc 关闭
+
+
+
+
+
+ 查找节点
+
+
+
+ ⌕
+
+ /
+
+
+
没有匹配的节点
+
+
+
+
+
+
+
语义护照
+
+
+
+
+
+
+
+
+
+
+
+
+
编写可达范围
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 路径
+ 点击路径的起点
+
+
+
+
+
+
+
+
+ 在图上点两个节点
+
+
+
+
+
+
+
+
先点起点,再点终点,沿箭头方向找路。
+
+
+
+
+ 语义透镜
+ 比较系统角色
+
+
+
+
最多选择两种语义类型。选择一种可显示其真实流量;选择两种只比较直接编写的关系。
+
+
选择一种类型以检查其节点和相连关系。
+
+
+
+
+
+
+
+
+
+ 语义雷达
+ 正在构建总览
+
+
+
+
+
+
点击节点拖动平移
+
+
语义雷达需要更多地图可见空间。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/architecture/features/branches-pr-status-dataflow.light.svg b/docs/assets/architecture/features/branches-pr-status-dataflow.light.svg
new file mode 100644
index 0000000..b90ee3b
--- /dev/null
+++ b/docs/assets/architecture/features/branches-pr-status-dataflow.light.svg
@@ -0,0 +1,655 @@
+
+
\ No newline at end of file
diff --git a/docs/assets/mermaid/README.md b/docs/assets/mermaid/README.md
index 26f9d0f..5c8b084 100644
--- a/docs/assets/mermaid/README.md
+++ b/docs/assets/mermaid/README.md
@@ -11,6 +11,7 @@
| [agentic-git-preferences-flow.mmd](features/agentic-git-preferences-flow.mmd) | flowchart LR · 偏好流向 | [agentic-git-preferences.md](../../features/agentic-git-preferences.md) L10-31 | [agentic-git-preferences-flow.html](../architecture/features/agentic-git-preferences-flow.html) | agentic-git-preferences.md | 与 `package.json` `hyperGit.agent.*` 四项配置核实一致(baseBranch 默认空、三项 multilineText) | ✅ 2026-09-11 |
| [claude-code-config-flow.mmd](features/claude-code-config-flow.mmd) | flowchart LR · 配置流向 | [claude-code-config.md](../../features/claude-code-config.md) L17-43 | [claude-code-config-flow.html](../architecture/features/claude-code-config-flow.html) | claude-code-config.md | 与 `adapter/claude-commands.ts`(QuickPick / 缺失确认后 mkdir+写{})、`engine/agent/claude-path.ts` 纯函数核实一致 | ✅ 2026-09-11 |
| [log-ci-status-dataflow.mmd](features/log-ci-status-dataflow.mmd) | flowchart LR · CI 取数 | [log-ci-status.md](../../features/log-ci-status.md) L9-31 | [log-ci-status-dataflow.html](../architecture/features/log-ci-status-dataflow.html) | log-ci-status.md | 与 `engine/ci/` + `adapter/ci/` + `protocol.ts` 核实一致(防抖 200ms、批量 ≤100 oid、pending 30s TTL、openExternal 主机校验) | ✅ 2026-09-11 |
+| [branches-pr-status-dataflow.mmd](features/branches-pr-status-dataflow.mmd) | flowchart LR · PR 徽标取数 | [branches-pr-status.md](../../features/branches-pr-status.md) 数据流节 | [branches-pr-status-dataflow.html](../architecture/features/branches-pr-status-dataflow.html) | branches-pr-status.md | 与 `engine/pr/` + `adapter/pr/github-pr-service.ts` + branches-tree 集成核实一致(TTL 60s、分页 ≤2×50、三级匹配、同实例引用相等守卫、safePrUrl 主机白名单) | ✅ 2026-10-16 |
| [log-commit-detail-panel-layout.mmd](features/log-commit-detail-panel-layout.mmd) | flowchart TB · 面板布局 | [log-commit-detail-panel.md](../../features/log-commit-detail-panel.md) L17-41 | [log-commit-detail-panel-layout.html](../architecture/features/log-commit-detail-panel-layout.html) | log-commit-detail-panel.md | 与 `log-webview.ts` 分栏实现(560px stacked 阈值、18%–75% / 15%–85% 钳制)及 `package.json` view/title 核实一致 | ✅ 2026-09-11 |
| [log-commit-detail-panel-dataflow.mmd](features/log-commit-detail-panel-dataflow.mmd) | flowchart LR · 取数数据流 | 同上 L50-65 | [log-commit-detail-panel-dataflow.html](../architecture/features/log-commit-detail-panel-dataflow.html) | 同上 | 与 `protocol.ts`(commitFiles 按 hash、commitDetail 按 forHash 校验)及 selectRow / requestPanelData / deselectRow 漏斗核实一致 | ✅ 2026-09-11 |
| [multi-root-repo-selection-toolbar.mmd](features/multi-root-repo-selection-toolbar.mmd) | flowchart LR · 切换入口 | [multi-root-repo-selection.md](../../features/multi-root-repo-selection.md) L7-18 | [multi-root-repo-selection-toolbar.html](../architecture/features/multi-root-repo-selection-toolbar.html) | multi-root-repo-selection.md | 修正漂移:按钮实为 `view/title` 原生标题栏项**直调命令** `hyperGit.selectRepository`,原「postMessage log/selectRepo」路径不存在,.mmd 已改 | ✅ 2026-09-11 |
diff --git a/docs/assets/mermaid/features/branches-pr-status-dataflow.mmd b/docs/assets/mermaid/features/branches-pr-status-dataflow.mmd
new file mode 100644
index 0000000..4bfef67
--- /dev/null
+++ b/docs/assets/mermaid/features/branches-pr-status-dataflow.mmd
@@ -0,0 +1,25 @@
+%% Mermaid 源图(单一事实源)· 溯源矩阵: docs/assets/mermaid/README.md
+%% 源文档: docs/features/branches-pr-status.md(数据流节)
+%% archify 成品: docs/assets/architecture/features/branches-pr-status-dataflow.html
+
+flowchart LR
+ subgraph Tree["Branches 树(首轮)"]
+ A["refresh / getChildren"] --> B["先渲染(无徽标不阻塞)"]
+ A --> C["ensurePrData 异步 kick"]
+ end
+ subgraph Svc["GitHubPrService(adapter)"]
+ C --> D{"可用?
+ 开关 / GitHub 远程 / 已登录"}
+ D -->|否| E["返回 null(零请求)"]
+ D -->|是| F["TTL 60s 缓存命中?"]
+ F -->|命中且数据未变| G["返回同一索引实例"]
+ F -->|未命中| H["GraphQL 分页 ≤2×50\nUPDATED_AT 倒序全态 PR"]
+ H --> I["buildPrIndex 预计算\nfork全名 / 同仓 / 唯一兜底"]
+ I --> J["写缓存(repoRoot + TTL)"]
+ end
+ J --> K{"索引 ≠ 快照?"}
+ G --> K
+ K -->|是| L["fire 一次二次渲染\n行右侧 #N 状态 + 图标着色"]
+ K -->|否| B
+ L --> M["悬停 Tooltip\nPR 号 / 标题 / Open on GitHub"]
+ E --> B
diff --git a/docs/features/branches-pr-status.md b/docs/features/branches-pr-status.md
new file mode 100644
index 0000000..4d6e19f
--- /dev/null
+++ b/docs/features/branches-pr-status.md
@@ -0,0 +1,80 @@
+# Branches 视图远程分支 PR 状态徽标
+
+> 在 Branches 视图(`hyperGit.branches`)**远程分支**行右侧显示关联 GitHub Pull Request 状态与
+> PR 号(如 `#138 Merged`),云图标按状态着色(Merged 紫 / Closed 红 / Open 绿 / Draft 弱化);
+> 悬停行以 Tooltip 展示 PR 号、标题与经主机白名单校验的 Open on GitHub 链接。数据源为 GitHub
+> GraphQL(最近更新的全态 PR 列表),按 origin 远程主机自动判定 github.com / GitHub Enterprise。
+
+## 数据流
+
+
+
+
+
+
+> [交互版](../assets/architecture/features/branches-pr-status-dataflow.html) · [Mermaid 源图](../assets/mermaid/features/branches-pr-status-dataflow.mmd)(图资产溯源见 [图资产索引](../assets/mermaid/README.md))
+
+## 刷新与终止性
+
+- **首轮先行渲染**:树刷新即渲染(无徽标),`ensurePrData` 异步 kick 取索引,**不阻塞建树**。
+- **无刷新循环**:索引就绪且与快照不同才 fire 一次二次渲染;TTL 命中与数据未变(`prsEqual`)均返回
+ **同一索引实例** → 引用相等守卫不再 fire。最坏每 TTL 到期多一轮渲染。
+- **多根切换**:`setRepoRoot` 清快照;取数完成后 `repoRoot` 已变则丢弃(防串仓);服务端为携带 repoRoot 校验的单槽缓存,切库即失效重取。
+- **登录/登出与取数源配置**:`GitHubAuth.onDidChange` 或 PR 开关 / `hyperGit.log.ci.*` / `github-enterprise.uri` 变化 → 服务推进代次(作废在途写回)+ 清缓存,经 `onDidInvalidate` 通知树清快照重取(登出即隐去徽标;切换取数仓后无需等待 git 事件)。
+
+## 分支 → PR 匹配(三级优先,宁缺勿错)
+
+剥 remote 前缀复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`):
+
+| Tier | 规则 | 场景 |
+|---|---|---|
+| a | `shortName === ${headOwner}/${headRefName}`,且首段 remote 的 URL host/owner/repo 与本仓 host 及 head owner/head repo 一致(owner/repo 大小写不敏感;fork 与 base 同实例、保留 base 仓库名) | remote 本身以 fork owner 命名(如 `mylogin/feature`);撞名劫持(login 与 remote 名撞名、含 `/` remote 名与键同形、remote 指向同 owner 他仓或异 forge 实例)被身份校验排除 |
+| b | 剥离后 `branch === headRefName && headOwner === repoOwner` | dependabot / 仓内功能分支(主场景) |
+| c | 剥离后 `branch === headRefName` 且候选**唯一** | 专属 fork remote;歧义(多 fork 同名)→ 不显示 |
+
+同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。
+
+## 认证与安全
+
+- 复用 **VS Code 内置 GitHub 认证**(`vscode.authentication`,范围 `repo`),凭证绝不经过 chat / 日志 / webview;与 CI 状态共享同一会话。
+- **静默优先**:`peek({createIfNone:false})` 绝不弹窗;未登录 → 零徽标、零请求。登录入口复用 Graph 视图标题栏「登录 GitHub」按钮(`hyperGit.ci.signIn`);该按钮由 CI 状态驱动,需 `hyperGit.log.ci.enabled` 开启(默认开启)——「CI 关 / PR 开」的组合下 Branches 视图暂无独立登录入口。
+- **反 SSRF**:Tooltip 链接经 `safePrUrl` 校验(https 且主机 ∈ {github.com, `*.github.com`, 仓库主机, api.仓库主机})后才入 `isTrusted` MarkdownString。
+- **取数主机策略**:仅 github.com 与已配置 `github-enterprise.uri` 的实例主机取数;其余「GitHub 形态」远程(origin 指向任意第三方主机)视同非 GitHub——零徽标、零请求,杜绝 token 外发。
+
+## 限流与性能
+
+- **分页硬上限 100 条**(UPDATED_AT 倒序 × 2×50):高流量仓中掉出前 100 的旧分支 PR 静默不显示(宁缺勿错),不做配置项。
+- **TTL 60s 缓存 + in-flight 去重**:最坏 2 请求/分钟(持续失败按 60s 退避,同口径限频);数据未变续期并返回同一实例。
+- **限流冷却独立**:读取 `rateLimit{remaining,resetAt}` 与 `Retry-After`,剩余点数 <100 或 403 时冷却(期间只走缓存);与 CI 服务冷却互不联动。
+- **降级**:失败/超时 → 走既有缓存(无缓存则无徽标)、60s 退避后重试;401 → 失效会话;断网/限流不崩溃、建树正常。
+
+## 边界行为
+
+| 场景 | 表现 |
+|---|---|
+| 非 GitHub 远程(GitLab 等) | 功能隐藏(零徽标、零请求) |
+| 未登录 GitHub | 零徽标、零请求(登录后即取,无需 git 事件) |
+| 配置关闭 | 零徽标、零请求 |
+| 多 fork 同名分支(匹配歧义) | 不显示徽标(宁缺勿错) |
+| 掉出最近 100 条 PR 的旧分支 | 静默无徽标 |
+| Favorites 中的远程分支 | 同样显示徽标(图标收藏黄优先于 PR 状态色) |
+| GitHub Enterprise | 按 origin 主机自动判定,使用 `github-enterprise` provider(需已配置 `github-enterprise.uri`) |
+
+## 配置
+
+| 键 | 默认 | 说明 |
+|---|---|---|
+| `hyperGit.branches.pr.enabled` | `true` | 总开关。关闭后零徽标、零请求。 |
+
+provider 与取数仓判定复用 `hyperGit.log.ci.provider` / `hyperGit.log.ci.remote`(默认 auto:按 origin 主机自动判定;provider 钉死为与远程主机不符的值时徽标休眠,remote 可重定向取数仓)——详见 [Log 视图 CI 状态](./log-ci-status.md)。
+
+## 实现
+
+- 引擎层(纯逻辑,Vitest 可测):[`engine/pr/`](../../src/engine/pr) — `types.ts`(PullRequestVM)、`graphql-query.ts`($variables 防注入列表查询)、`model.ts`(宽容解析 + prBadge + prsEqual)、`match.ts`(三级匹配索引)。
+- 适配层(唯一触碰 vscode/网络):[`adapter/pr/github-pr-service.ts`](../../src/adapter/pr/github-pr-service.ts)(缓存/分页/限流/safePrUrl);[`adapter/ci/github-ci-service.ts`](../../src/adapter/ci/github-ci-service.ts) 增 `getGitHubContext()`(remote/provider 单一事实源);[`adapter/ci/github-auth.ts`](../../src/adapter/ci/github-auth.ts) 增 `onDidChange` 透传。
+- 渲染:[`adapter/tree/branches-tree.ts`](../../src/adapter/tree/branches-tree.ts) — `ensurePrData` 异步 kick、`describe` 追加 `#N 状态`、`prThemeColor` 图标着色(收藏黄/活动蓝优先);[`adapter/tree/tree-tooltip.ts`](../../src/adapter/tree/tree-tooltip.ts) — `mdTooltip` 增 `links` 链接行(仅含链接实例放开 isTrusted)。
+
+## 验证
+
+1. `pnpm run check-types` + `pnpm run lint` + `pnpm run test:unit`(含 `tests/unit/pr-*.test.ts`)全绿。
+2. Extension Development Host(F5)在 GitHub 仓库:远程分支行右侧出现 `#N Open/Merged/Closed/Draft` + 图标着色;悬停见 PR 号/标题/可点击 Open on GitHub;收藏远程条目同样生效(黄标优先);未登录/非 GitHub/配置关闭 → 零徽标零请求;PR 状态变化 ≤60s(下次树刷新 + TTL 到期)自然更新;多根切换无串仓残留。
diff --git a/docs/features/log-ci-status.md b/docs/features/log-ci-status.md
index fc14c33..832c935 100644
--- a/docs/features/log-ci-status.md
+++ b/docs/features/log-ci-status.md
@@ -19,7 +19,8 @@
- 范围 `repo`:覆盖私有仓库的 Checks(Actions)+ Commit Status 读取(`repo:status` 不覆盖 Checks API)。
- **静默优先**:加载时 `getSession({createIfNone:false})` 仅复用已有会话,**绝不自动弹窗**;仅当用户点击 Graph 标题栏「登录 GitHub」
图标按钮(`$(sign-in)`,`when: hyperGit.log.ciNeedsSignIn`,授权完成自动隐藏)时才以 `{createIfNone:true}` 触发原生授权 UI。未登录 → 仅显该按钮、不渲染图标、不发请求。
-- **反 SSRF**:Tooltip 的跳转链接(`detailsUrl` / `targetUrl`,属「观察内容」)由 host 校验 `https` 且主机 ∈ {仓库主机、`*.github.com`} 后才 `openExternal`。
+- **反 SSRF**:Tooltip 的跳转链接(`detailsUrl` / `targetUrl`,属「观察内容」)由 host 校验 `https` 且主机过共享白名单谓词(github.com、`*.github.com`、仓库主机、api.仓库主机)后才 `openExternal`。
+- **取数主机策略**:仅 github.com 与已配置 `github-enterprise.uri` 的实例主机取数;其余「GitHub 形态」远程(origin 指向任意第三方主机)视同非 GitHub——零徽标、零请求,杜绝 token 外发(与 Branches PR 徽标同策略,见 [Branches 视图 PR 徽标](./branches-pr-status.md))。
## 限流与性能
diff --git a/package.json b/package.json
index 6035cb8..2e8bc8b 100644
--- a/package.json
+++ b/package.json
@@ -1530,7 +1530,13 @@
"GitHub Enterprise (self-hosted)"
],
"default": "auto",
- "markdownDescription": "CI data source. `auto` is resolved by the origin host (github.com → github.com, otherwise GitHub Enterprise).",
+ "markdownDescription": "CI data source. `auto` resolves github.com remotes to GitHub.com and remotes on the configured `github-enterprise.uri` host to GitHub Enterprise; any other host is treated as non-GitHub (feature hidden, zero requests).",
+ "scope": "resource"
+ },
+ "hyperGit.branches.pr.enabled": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Show the associated GitHub pull request (e.g. `#138 Merged`) on the right of each remote branch in the Branches view, with the branch icon tinted by PR state. Uses the most recently updated 100 pull requests; hover a branch for the title and an Open on GitHub link. Requires a GitHub remote — if nothing appears, sign in via the Graph view's GitHub sign-in action (shown when CI status is enabled, on by default).",
"scope": "resource"
}
}
diff --git a/src/adapter/ci/github-auth.ts b/src/adapter/ci/github-auth.ts
index 5489a88..22d0ded 100644
--- a/src/adapter/ci/github-auth.ts
+++ b/src/adapter/ci/github-auth.ts
@@ -17,13 +17,18 @@ const GITHUB_SCOPES = ['repo'] as const;
export class GitHubAuth {
/** 已缓存的会话(undefined 表示「已探测且无会话」;has() 区分「未探测」与「无会话」避免重复 getSession)。 */
private readonly cached = new Map();
+ private readonly _onDidChange = new vscode.EventEmitter();
+ /** 会话增删/失效时 fire(与内部缓存失效同一时机),供 Branches PR 徽标等依赖方刷新。 */
+ readonly onDidChange = this._onDidChange.event;
constructor(disposables: vscode.Disposable[]) {
disposables.push(
+ this._onDidChange,
vscode.authentication.onDidChangeSessions((e) => {
const id = e.provider.id;
if (id === 'github' || id === 'github-enterprise') {
this.cached.delete(id as GitHubAuthProviderId);
+ this._onDidChange.fire();
}
}),
);
diff --git a/src/adapter/ci/github-ci-service.ts b/src/adapter/ci/github-ci-service.ts
index 989562d..eb29ee6 100644
--- a/src/adapter/ci/github-ci-service.ts
+++ b/src/adapter/ci/github-ci-service.ts
@@ -14,8 +14,9 @@ import type { GitRepositoryService } from '../git-repository-service';
import type { Logger } from '../../infra/logger';
import { GitHubAuth, type GitHubAuthProviderId } from './github-auth';
import { buildCiQuery } from '../../engine/ci/graphql-query';
-import { extractRateLimit, parseCiResponse } from '../../engine/ci/model';
-import { authProviderId, graphqlEndpoint, parseGitHubRemote, type GitHubRemote } from '../../engine/ci/remote-parser';
+import { extractRateLimit, parseCiResponse, parseRetryAfterMs } from '../../engine/ci/model';
+import { authProviderId, graphqlEndpoint, isAllowedGitHubHost, isGitHubInstanceHost, parseGitHubRemote, type GitHubRemote } from '../../engine/ci/remote-parser';
+import { fetchWithTimeout } from './graphql-http';
import type { CiStatusVM } from '../../engine/ci/types';
/** 单次 GraphQL 拉取的 oid 上限(与可见窗口粒度对齐,文档体积与单点成本均衡)。 */
@@ -40,6 +41,8 @@ export interface CiServiceStatus {
interface RemoteCacheEntry {
readonly repoRoot: string;
+ /** 构建缓存时的 remotes 签名(name + push/fetch URL);remotes 增删/改址后签名不符即失效。 */
+ readonly remotesKey: string;
readonly remote: GitHubRemote | null;
}
@@ -102,8 +105,8 @@ export class GitHubCiService implements vscode.Disposable {
}
const remote = this.resolveRemote();
const provider = remote ? this.providerIdFor(remote) : null;
- if (!remote || !provider) {
- return { available: false, needsAuth: false };
+ if (!remote || !provider || !isGitHubInstanceHost(remote.host, this.enterpriseUri())) {
+ return { available: false, needsAuth: false }; // 含「形态可解析」但非已知 GitHub 实例的远程 → 视同非 GitHub
}
if (typeof globalThis.fetch !== 'function') {
return { available: false, needsAuth: false, error: 'No global fetch in current runtime' };
@@ -125,8 +128,8 @@ export class GitHubCiService implements vscode.Disposable {
}
const remote = this.resolveRemote();
const provider = remote ? this.providerIdFor(remote) : null;
- if (!remote || !provider) {
- return result;
+ if (!remote || !provider || !isGitHubInstanceHost(remote.host, this.enterpriseUri())) {
+ return result; // 含「形态可解析」但非已知 GitHub 实例的远程 → 视同非 GitHub(零请求,防 token 外发)
}
const uniq = [...new Set(hashes)];
@@ -175,7 +178,8 @@ export class GitHubCiService implements vscode.Disposable {
async signIn(): Promise {
const remote = this.resolveRemote();
const provider = remote ? this.providerIdFor(remote) : null;
- if (!remote || !provider) {
+ // 与 status()/getStatuses() 同门(策略面收口):非可信实例主机不发起登录(登录后取数也被门控)。
+ if (!remote || !provider || !isGitHubInstanceHost(remote.host, this.enterpriseUri())) {
return false;
}
try {
@@ -188,26 +192,24 @@ export class GitHubCiService implements vscode.Disposable {
/** 校验后打开外部链接(反 SSRF:仅放行 GitHub 主机)。 */
async openExternal(rawUrl: string): Promise {
- let uri: vscode.Uri;
+ // 用 WHATWG URL 解析(与浏览器同规范):vscode.Uri 的 authority 提取与浏览器对 `\` 等字符
+ // 的归一存在分歧(如 `https://evil.com\@github.com/` 浏览器实际访问 evil.com),会绕过白名单。
+ let url: URL;
try {
- uri = vscode.Uri.parse(rawUrl, true);
+ url = new URL(rawUrl);
} catch {
return;
}
- if (uri.scheme !== 'https') {
+ if (url.protocol !== 'https:') {
return;
}
- const host = uri.authority.toLowerCase();
+ // 主机白名单走共享谓词(单一事实源,与 Branches PR safePrUrl 同口径);hostname 已归一(无端口/userinfo)。
const remote = this.resolveRemote();
- const allowed =
- host === 'github.com' ||
- host.endsWith('.github.com') ||
- (!!remote && (host === remote.host || host === `api.${remote.host}`));
- if (!allowed) {
+ if (!isAllowedGitHubHost(url.hostname, remote?.host)) {
this.logger.warn(`拒绝打开非 GitHub 链接:${rawUrl}`);
return;
}
- await vscode.env.openExternal(uri);
+ await vscode.env.openExternal(vscode.Uri.parse(url.toString(), true));
}
dispose(): void {
@@ -222,6 +224,11 @@ export class GitHubCiService implements vscode.Disposable {
return vscode.workspace.getConfiguration('hyperGit').get('log.ci.enabled', true);
}
+ /** 已配置的 GHE 实例 uri(github-enterprise provider 的事实源;空 = 未配置)。 */
+ private enterpriseUri(): string | undefined {
+ return vscode.workspace.getConfiguration('github-enterprise').get('uri');
+ }
+
private providerConfig(): 'auto' | 'github.com' | 'github-enterprise' {
return vscode.workspace.getConfiguration('hyperGit').get<'auto' | 'github.com' | 'github-enterprise'>(
'log.ci.provider',
@@ -246,19 +253,35 @@ export class GitHubCiService implements vscode.Disposable {
return authProviderId(remote);
}
- /** 对外暴露当前仓库的 GitHub 坐标(复用内部 repoRoot 缓存),供 Commit 详情面板生成提交页 URL。 */
+ /** 对外暴露当前仓库的 GitHub 坐标(复用内部 repoRoot + remotes 签名缓存),供 Commit 详情面板生成提交页 URL。 */
getGitHubRemote(): GitHubRemote | null {
return this.resolveRemote();
}
- /** 解析当前仓库远程为 GitHub 坐标(按 repoRoot 缓存;优先配置名 → origin → 首个可解析)。 */
+ /**
+ * 对外暴露远程坐标 + 认证 provider(复用内部 repoRoot 缓存与 provider 策略),供 Branches
+ * PR 徽标等同域 GitHub 集成复用——remote 解析与 provider 判定的单一事实源,避免 PR 侧复制策略。
+ * 与 `hyperGit.log.ci.enabled` 开关无关(该开关只门控 CI 取数),PR 功能独立启停。
+ */
+ getGitHubContext(): { remote: GitHubRemote; provider: GitHubAuthProviderId } | null {
+ const remote = this.resolveRemote();
+ const provider = remote ? this.providerIdFor(remote) : null;
+ return remote && provider ? { remote, provider } : null;
+ }
+
+ /**
+ * 解析当前仓库远程为 GitHub 坐标(按 repoRoot + remotes 签名缓存;优先配置名 → origin → 首个可解析)。
+ * 签名含 name/URL:remotes 首帧未填充、后续 `git remote add` / `set-url` 均会使缓存失效重解析,
+ * 避免「首解析时机」冻结(null 被整会话缓存 / 改址后持续查询旧仓)。
+ */
private resolveRemote(): GitHubRemote | null {
const repoRoot = this.service.repoRoot ?? '';
- if (this.remoteCache && this.remoteCache.repoRoot === repoRoot) {
+ const remotes = this.service.repo?.state.remotes ?? [];
+ const remotesKey = remotes.map((r) => `${r.name}=${r.pushUrl ?? ''};${r.fetchUrl ?? ''}`).join('|');
+ if (this.remoteCache && this.remoteCache.repoRoot === repoRoot && this.remoteCache.remotesKey === remotesKey) {
return this.remoteCache.remote;
}
let remote: GitHubRemote | null = null;
- const remotes = this.service.repo?.state.remotes ?? [];
if (remotes.length > 0) {
const preferred = this.remoteNameConfig().trim();
const pick = (r: { fetchUrl?: string; pushUrl?: string }): string => r.pushUrl ?? r.fetchUrl ?? '';
@@ -281,7 +304,7 @@ export class GitHubCiService implements vscode.Disposable {
}
}
}
- this.remoteCache = { repoRoot, remote };
+ this.remoteCache = { repoRoot, remotesKey, remote };
return remote;
}
@@ -348,27 +371,31 @@ export class GitHubCiService implements vscode.Disposable {
await this.semaphore.acquire();
try {
- const res = await this.fetchWithTimeout(fetchImpl, endpoint, {
+ const { res, done } = await fetchWithTimeout(fetchImpl, endpoint, {
method: 'POST',
headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'hyper-git-vscode' },
body,
- });
- if (res.status === 401) {
- this.auth.invalidate(provider); // token 失效,强制下次重解析会话
- return map;
- }
- if (res.status === 403) {
- const retryAfter = res.headers.get('retry-after');
- if (retryAfter) {
- this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + Number(retryAfter) * 1000);
- }
- return map;
- }
+ }, REQUEST_TIMEOUT_MS, this.abortController.signal);
let json: unknown;
try {
- json = await res.json();
- } catch {
- return map;
+ if (res.status === 401) {
+ this.auth.invalidate(provider); // token 失效,强制下次重解析会话
+ return map;
+ }
+ if (res.status === 403) {
+ const ms = parseRetryAfterMs(res.headers.get('retry-after'));
+ if (ms !== null) {
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms);
+ }
+ return map;
+ }
+ try {
+ json = await res.json(); // 超时/dispose 保护伞覆盖到 body 读毕(done 前)
+ } catch {
+ return map;
+ }
+ } finally {
+ done(); // 解除定时器与 dispose 监听(401/403 早退与 body 失败同样覆盖)
}
const errors = (json as { errors?: unknown[] })?.errors;
if (Array.isArray(errors) && errors.length > 0) {
@@ -392,24 +419,6 @@ export class GitHubCiService implements vscode.Disposable {
return map;
}
- /** fetch + 超时/销毁可中止(每个请求独立 AbortController,挂到全局 dispose 信号)。 */
- private async fetchWithTimeout(
- fetchImpl: typeof globalThis.fetch,
- url: string,
- init: RequestInit,
- ): Promise {
- const reqAc = new AbortController();
- const onDispose = (): void => reqAc.abort();
- this.abortController.signal.addEventListener('abort', onDispose, { once: true });
- const timer = setTimeout(() => reqAc.abort(), REQUEST_TIMEOUT_MS);
- try {
- return await fetchImpl(url, { ...init, signal: reqAc.signal });
- } finally {
- clearTimeout(timer);
- this.abortController.signal.removeEventListener('abort', onDispose);
- }
- }
-
private async acquireToken(provider: GitHubAuthProviderId): Promise {
try {
const session = await this.auth.peek(provider);
diff --git a/src/adapter/ci/graphql-http.ts b/src/adapter/ci/graphql-http.ts
new file mode 100644
index 0000000..6822f02
--- /dev/null
+++ b/src/adapter/ci/graphql-http.ts
@@ -0,0 +1,39 @@
+/**
+ * GitHub GraphQL 共享 HTTP 传输(CI / Branches PR 两个服务共用)。
+ *
+ * 超时/销毁可中止的 fetch 机制归此处单一实现,消除两服务各自维护同一份超时语义的漂移面;
+ * 冷却/限流/降级策略仍归各服务(机制与策略分离)。每请求独立 AbortController,超时与
+ * 调用方 dispose 信号任一触发即中止;**保护伞覆盖到 body 读取完成**(done() 前)——
+ * 仅到响应头即清定时器会使慢滴 body 的 res.json() 无界挂起,钉死唯一 inflight 槽。
+ */
+
+/** fetch + 单请求超时 + dispose 可中止;返回响应与 done()(body 消费完成后调用,解除定时器与监听)。 */
+export async function fetchWithTimeout(
+ fetchImpl: typeof globalThis.fetch,
+ url: string,
+ init: RequestInit,
+ timeoutMs: number,
+ disposeSignal: AbortSignal,
+): Promise<{ res: Response; done: () => void }> {
+ if (disposeSignal.aborted) {
+ // 已中止的信号不会再向事后注册的监听器分发事件——入口短路,兑现「任一触发即中止」承诺。
+ throw Object.assign(new Error('dispose signal already aborted'), { name: 'AbortError' });
+ }
+ const reqAc = new AbortController();
+ const onDispose = (): void => reqAc.abort();
+ disposeSignal.addEventListener('abort', onDispose, { once: true });
+ const timer = setTimeout(() => reqAc.abort(), timeoutMs);
+ let res: Response;
+ try {
+ res = await fetchImpl(url, { ...init, signal: reqAc.signal });
+ } catch (e) {
+ clearTimeout(timer);
+ disposeSignal.removeEventListener('abort', onDispose);
+ throw e;
+ }
+ const done = (): void => {
+ clearTimeout(timer);
+ disposeSignal.removeEventListener('abort', onDispose);
+ };
+ return { res, done };
+}
diff --git a/src/adapter/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts
new file mode 100644
index 0000000..cf3d7ae
--- /dev/null
+++ b/src/adapter/pr/github-pr-service.ts
@@ -0,0 +1,333 @@
+/**
+ * Branches 视图 PR 徽标服务(adapter 层,唯一触碰 vscode/网络)。
+ *
+ * 编排:复用 CI 服务的 remote/provider 单一事实源(getGitHubContext)→ 取 token → GraphQL
+ * 分页拉取最近更新的 PR(UPDATED_AT 倒序,全态)→ engine `buildPrIndex` 预计算匹配索引 →
+ * 单槽缓存(条目携带 repoRoot 供读路径校验,切库即失效重取)+ TTL 60s;数据与索引输入均未变才
+ * 续期并返回同一实例,供树侧引用相等判无变化。登录态或取数源配置变化即清缓存并作废在途取数
+ * (登出不留旧会话数据、切换取数仓后立即可重取);未授权不缓存(登录后即取),失败按 60s 退避、限流冷却降级为「无徽标 / 走缓存」,
+ * 绝不抛错、绝不阻塞建树。
+ *
+ * 安全:token 仅用于 Authorization 头;`safePrUrl` 对入 Tooltip 的链接做 https + GitHub
+ * 主机白名单校验(反 SSRF,镜像 CI 服务 openExternal 的姿态)。
+ */
+
+import * as vscode from 'vscode';
+import type { GitRepositoryService } from '../git-repository-service';
+import type { Logger } from '../../infra/logger';
+import type { GitHubAuth, GitHubAuthProviderId } from '../ci/github-auth';
+import type { GitHubCiService } from '../ci/github-ci-service';
+import { graphqlEndpoint, isAllowedGitHubHost, isGitHubInstanceHost, parseGitHubRemote, type GitHubRemote } from '../../engine/ci/remote-parser';
+import { extractRateLimit, parseRetryAfterMs } from '../../engine/ci/model';
+import { fetchWithTimeout } from '../ci/graphql-http';
+import { buildPrQuery } from '../../engine/pr/graphql-query';
+import { parsePullRequestsResponse, prsEqual } from '../../engine/pr/model';
+import { buildPrIndex, type PrRemoteInfo, type RemotePrIndex } from '../../engine/pr/match';
+import type { PullRequestVM } from '../../engine/pr/types';
+
+/** 单页 PR 数(GitHub 单页上限 100,取 50 平衡时延与点数)。 */
+const PAGE_SIZE = 50;
+/** 分页硬上限:最多取最近更新的 100 条 PR(超出者静默无徽标,文档化边界)。 */
+const MAX_PAGES = 2;
+/** 索引 TTL(ms):分支 PR 状态变化经下一次树刷新 + TTL 到期后自然更新。 */
+const TTL_MS = 60_000;
+/** 失败退避(ms):持续失败态限频重试,与 TTL 同口径(成功/失败路径均最坏 2 请求/分钟)。 */
+const FAILURE_BACKOFF_MS = 60_000;
+/** 单请求超时(ms)。 */
+const REQUEST_TIMEOUT_MS = 15_000;
+/** 剩余点数低于此阈值进入限流冷却(与 CI 服务同阈值,冷却互相独立)。 */
+const RATE_FLOOR = 100;
+
+interface CacheEntry {
+ readonly repoRoot: string;
+ readonly prs: readonly PullRequestVM[];
+ readonly index: RemotePrIndex;
+ /** 构建索引时的输入快照(remote 增删/改名/改址影响三级匹配),续期须连同比较。 */
+ readonly init: { readonly repoOwner: string; readonly repoHost: string; readonly repo: string; readonly remotes: readonly PrRemoteInfo[] };
+ readonly expires: number;
+}
+
+/** remotes 快照相等(顺序敏感;git 返回顺序稳定,逐项比较 name + 解析 host/owner/repo)。 */
+function sameRemotes(a: readonly PrRemoteInfo[], b: readonly PrRemoteInfo[]): boolean {
+ return (
+ a.length === b.length &&
+ a.every((v, i) => v.name === b[i].name && v.host === b[i].host && v.owner === b[i].owner && v.repo === b[i].repo)
+ );
+}
+
+export class GitHubPrService implements vscode.Disposable {
+ /** 取数失效通知(登录态或取数源配置变化,服务已清缓存并推进代次),供树侧清快照重取——
+ * 失效依赖清单只在服务一处维护(单一事实源,树侧不另列配置键)。 */
+ private readonly _onDidInvalidate = new vscode.EventEmitter();
+ readonly onDidInvalidate = this._onDidInvalidate.event;
+
+ private cache: CacheEntry | null = null;
+ private inflight: { readonly key: string; readonly promise: Promise } | undefined;
+ /** 取数代次:登录态或取数源配置变化即推进并清缓存,在途取数完成时据此作废(防写回复活旧会话/旧取数仓数据)。 */
+ private fetchGeneration = 0;
+ private cooldownUntil = 0;
+ /** 失败退避截止:网络/解析失败后限频重试(登录/配置变化即重置,登录后立即重取)。 */
+ private failureCooldownUntil = 0;
+ private readonly abortController = new AbortController();
+ private readonly disposables: vscode.Disposable[] = [];
+
+ constructor(
+ private readonly service: GitRepositoryService,
+ private readonly ci: GitHubCiService,
+ private readonly auth: GitHubAuth,
+ private readonly logger: Logger,
+ ) {
+ this.disposables.push(
+ // 配置变化 → invalidate()(推进代次 + 清缓存/退避 + 经 onDidInvalidate 通知树侧重取):
+ // 自身开关 + 取数源依赖(log.ci.provider/remote 决定取数仓、github-enterprise.uri 决定可信实例)。
+ vscode.workspace.onDidChangeConfiguration((e) => {
+ if (e.affectsConfiguration('hyperGit.branches.pr') || e.affectsConfiguration('hyperGit.log.ci') || e.affectsConfiguration('github-enterprise')) {
+ this.invalidate();
+ }
+ }),
+ // 登录/登出:清缓存与退避 + 代次推进(作废在途取数写回),保证「登出即隐去徽标、登录后即取」。
+ this.auth.onDidChange(() => this.invalidate()),
+ );
+ }
+
+ /**
+ * 拉取(或复用)当前仓库的 PR 匹配索引。不可用(非 GitHub / 未登录 / 限流 / 关闭 / 失败)→
+ * null,绝不抛错。数据未变时返回缓存的同一索引实例(树侧引用相等即免 fire)。
+ */
+ async getPullRequests(): Promise {
+ if (!this.enabled()) {
+ return null;
+ }
+ const repoRoot = this.service.repoRoot ?? '';
+ const ctx = this.ci.getGitHubContext();
+ if (!ctx) {
+ return null; // 非 GitHub 远程 / provider 不匹配 → 静默休眠
+ }
+ if (!this.isTrustedRemoteHost(ctx.remote)) {
+ return null; // 「形态可解析」但非 github.com 且非已配置 GHE 实例的远程 → 视同非 GitHub(防 token 外发任意主机)
+ }
+ const cacheHit = this.cacheMatches(repoRoot, ctx.remote) ? this.cache!.index : null;
+ if (this.inCooldown() || Date.now() < this.failureCooldownUntil) {
+ return cacheHit; // 限流冷却 / 失败退避期只走(上下文一致的)缓存,镜像 CI 策略
+ }
+ if (cacheHit && Date.now() < this.cache!.expires) {
+ return cacheHit;
+ }
+ // 在途去重键 = 仓根 + 取数上下文(host/owner/repo):git remote set-url 等改址竞态下,
+ // 旧上下文的在途结果不交给新调用方(防短暂串仓);写缓存侧由 cacheMatches 再兜底。
+ const inflightKey = `${repoRoot}|${ctx.remote.host}/${ctx.remote.owner}/${ctx.remote.repo}`;
+ if (this.inflight && this.inflight.key === inflightKey) {
+ return this.inflight.promise;
+ }
+ this.inflight = { key: inflightKey, promise: this.fetchAll(ctx.remote, ctx.provider, repoRoot) };
+ try {
+ return await this.inflight.promise;
+ } finally {
+ // 只清自己的在途(跨根/跨上下文并发时后起者已占位,不得误清)。
+ if (this.inflight?.key === inflightKey) {
+ this.inflight = undefined;
+ }
+ }
+ }
+
+ /** Tooltip 链接防 SSRF 校验:https 且主机过共享白名单谓词(github.com、*.github.com、仓库主机、api.仓库主机)。 */
+ safePrUrl(rawUrl: string): string | null {
+ let uri: URL;
+ try {
+ uri = new URL(rawUrl);
+ } catch {
+ return null;
+ }
+ if (uri.protocol !== 'https:') {
+ return null;
+ }
+ const remote = this.ci.getGitHubContext()?.remote;
+ return isAllowedGitHubHost(uri.hostname, remote?.host) ? uri.toString() : null;
+ }
+
+ dispose(): void {
+ this.abortController.abort();
+ this._onDidInvalidate.dispose();
+ this.disposables.forEach((d) => d.dispose());
+ }
+
+ // ─── 内部 ────────────────────────────────────────────────────────────────
+
+ /** 取数失效:推进代次(作废在途写回)+ 清缓存与退避 + 通知树侧清快照重取。 */
+ private invalidate(): void {
+ this.fetchGeneration++;
+ this.cache = null;
+ this.failureCooldownUntil = 0;
+ this.cooldownUntil = 0; // 限流冷却随取数代次一并复位(限流按 token/实例计,切换后新会话立即可重取)
+ this._onDidInvalidate.fire();
+ }
+
+ /**
+ * 缓存有效性单一判定(TTL 命中 / 冷却续供 / 失败续显三条读路径共用,防守卫不对称漂移):
+ * 仓根一致且取数上下文(host/owner/repo)一致——远程改址/换仓/换 provider 后不续供旧仓
+ * 索引(宁缺勿错)。
+ */
+ private cacheMatches(repoRoot: string, remote: GitHubRemote): boolean {
+ return (
+ !!this.cache &&
+ this.cache.repoRoot === repoRoot &&
+ this.cache.init.repoHost === remote.host &&
+ this.cache.init.repoOwner === remote.owner &&
+ this.cache.init.repo === remote.repo
+ );
+ }
+
+ /**
+ * 取数主机策略(SSOT:{@link isGitHubInstanceHost},CI 服务同源共用):仅 github.com 或与
+ * 已配置 `github-enterprise.uri` 一致的实例主机可取数;其余「GitHub 形态」远程视同非 GitHub
+ * (零徽标、零请求),杜绝把 GHE token 发往任意主机。
+ */
+ private isTrustedRemoteHost(remote: GitHubRemote): boolean {
+ return isGitHubInstanceHost(remote.host, vscode.workspace.getConfiguration('github-enterprise').get('uri'));
+ }
+
+ private enabled(): boolean {
+ return vscode.workspace.getConfiguration('hyperGit').get('branches.pr.enabled', true);
+ }
+
+ private inCooldown(): boolean {
+ return Date.now() < this.cooldownUntil;
+ }
+
+ /** 全量取数(分页 ≤ MAX_PAGES)→ 建索引 + 写缓存;任一页失败 → null(不缓存,留待重试)。 */
+ private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise {
+ const generation = this.fetchGeneration; // 取数期间的代次(登录态/取数源配置变化后作废)
+ // remotes 与 repoRoot 同时采样(首个 await 之前):完成后才读会取到切库后他仓的列表,错配写入缓存。
+ // name + URL 解析 host/owner/repo:供 Tier a 身份校验(不可解析 → null;可解析但 host 与本仓
+ // 不同实例的 remote 也被 Tier a 排除——fork 与 base 同 forge 实例)。
+ const remotes: readonly PrRemoteInfo[] = (this.service.repo?.state.remotes ?? []).map((r) => {
+ const g = parseGitHubRemote(r.pushUrl ?? r.fetchUrl ?? '');
+ return { name: r.name, host: g?.host ?? null, owner: g?.owner ?? null, repo: g?.repo ?? null };
+ });
+ const fetchImpl = globalThis.fetch;
+ if (typeof fetchImpl !== 'function') {
+ this.logger.warn('当前运行时无全局 fetch,PR 徽标不可用');
+ return null;
+ }
+ let token: string | null;
+ try {
+ token = (await this.auth.peek(provider))?.accessToken ?? null;
+ } catch {
+ return null; // provider 未注册(GHE 未配置 uri)→ 休眠
+ }
+ if (!token) {
+ return null; // 未授权:不缓存,登录后即取
+ }
+ // 网络已尝试后的失败统一走 fail():失败走**上下文一致的**旧缓存(cacheMatches 单一判定,
+ // 旧索引续显而非闪烁隐现),无缓存才无徽标;失败退避只在代次未变时武装(invalidate 刚复位
+ // 的退避不被在途旧代次的失败重新盖住——登录后/取数源切换后立即可重取)。
+ const fail = (): RemotePrIndex | null => {
+ if (generation === this.fetchGeneration) {
+ this.failureCooldownUntil = Date.now() + FAILURE_BACKOFF_MS;
+ }
+ return this.cacheMatches(repoRoot, remote) ? this.cache!.index : null;
+ };
+
+ const prs: PullRequestVM[] = [];
+ let after: string | null = null;
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const { query } = buildPrQuery({ first: PAGE_SIZE, after });
+ const json = await this.gql(remote, provider, token, query, after, generation);
+ if (!json) {
+ return fail();
+ }
+ const parsed = parsePullRequestsResponse(json);
+ if (!parsed) {
+ return fail();
+ }
+ prs.push(...parsed.prs);
+ if (!parsed.hasNextPage || !parsed.endCursor) {
+ break;
+ }
+ after = parsed.endCursor;
+ }
+
+ // 数据与索引输入(owner/remotes 快照)均未变:续期并返回同一索引实例(树侧引用相等 → 免 fire)。
+ if (
+ this.cache &&
+ this.cache.repoRoot === repoRoot &&
+ prsEqual(this.cache.prs, prs) &&
+ this.cache.init.repoOwner === remote.owner &&
+ this.cache.init.repoHost === remote.host &&
+ this.cache.init.repo === remote.repo &&
+ sameRemotes(this.cache.init.remotes, remotes)
+ ) {
+ this.cache = { ...this.cache, expires: Date.now() + TTL_MS };
+ return this.cache.index;
+ }
+ if (generation !== this.fetchGeneration) {
+ return null; // 取数期间登录态或取数源配置已变:结果作废,不写缓存
+ }
+ const index = buildPrIndex(prs, { repoOwner: remote.owner, repoHost: remote.host, remotes });
+ this.cache = { repoRoot, prs, index, init: { repoOwner: remote.owner, repoHost: remote.host, repo: remote.repo, remotes }, expires: Date.now() + TTL_MS };
+ return index;
+ }
+
+ /** 单次 GraphQL 请求(超时/401/403/限流处理,镜像 CI 服务 runBatch 的降级姿态)。 */
+ private async gql(
+ remote: GitHubRemote,
+ provider: GitHubAuthProviderId,
+ token: string,
+ query: string,
+ after: string | null,
+ generation: number,
+ ): Promise {
+ let res: Response;
+ let done: () => void;
+ try {
+ ({ res, done } = await fetchWithTimeout(
+ globalThis.fetch,
+ graphqlEndpoint(remote),
+ {
+ method: 'POST',
+ headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'hyper-git-vscode' },
+ body: JSON.stringify({ query, variables: { owner: remote.owner, name: remote.repo, after } }),
+ },
+ REQUEST_TIMEOUT_MS,
+ this.abortController.signal,
+ ));
+ } catch (e) {
+ if ((e as Error)?.name !== 'AbortError') {
+ this.logger.warn(`PR 请求失败:${(e as Error).message ?? e}`);
+ }
+ return null;
+ }
+ let json: unknown;
+ try {
+ if (res.status === 401) {
+ this.auth.invalidate(provider); // token 失效,强制下次重解析会话
+ return null;
+ }
+ if (res.status === 403) {
+ const ms = parseRetryAfterMs(res.headers.get('retry-after'));
+ if (ms !== null && generation === this.fetchGeneration) {
+ // 代次已变(登录态/取数源切换)则不武装冷却,保住 invalidate 的复位(立即可重取)。
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms);
+ }
+ return null;
+ }
+ try {
+ json = await res.json(); // 超时/dispose 保护伞覆盖到 body 读毕(done 前)
+ } catch {
+ return null;
+ }
+ } finally {
+ done(); // 解除定时器与 dispose 监听(401/403 早退与 body 失败同样覆盖)
+ }
+ const errors = (json as { errors?: unknown[] })?.errors;
+ if (Array.isArray(errors) && errors.length > 0) {
+ this.logger.warn(`PR 查询返回错误:${JSON.stringify(errors[0])}`);
+ }
+ const rl = extractRateLimit(json);
+ if (rl && rl.remaining !== null && rl.remaining < RATE_FLOOR && generation === this.fetchGeneration) {
+ const until = rl.resetAt ? Date.parse(rl.resetAt) : NaN;
+ this.cooldownUntil = Math.max(this.cooldownUntil, Number.isNaN(until) ? Date.now() + 60_000 : until);
+ }
+ return json;
+ }
+}
diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts
index 90b8d79..3c617ba 100644
--- a/src/adapter/tree/branches-tree.ts
+++ b/src/adapter/tree/branches-tree.ts
@@ -2,8 +2,12 @@ import * as vscode from 'vscode';
import type { Ref } from '../../types/git';
import type { GitRepositoryService } from '../git-repository-service';
import type { BranchFavorites } from '../branch-favorites';
+import type { GitHubPrService } from '../pr/github-pr-service';
import { FOR_EACH_REF_FORMAT, type RawRef, parseForEachRef } from '../../engine/ref/for-each-ref';
import { buildRefTree, type RefTreeNode } from '../../engine/ref/ref-tree';
+import { prBadge, prStateLabel } from '../../engine/pr/model';
+import type { RemotePrIndex } from '../../engine/pr/match';
+import type { PullRequestVM } from '../../engine/pr/types';
import { mdTooltip } from './tree-tooltip';
export type BranchGroupId = 'favorites' | 'local' | 'remote' | 'tags';
@@ -47,6 +51,20 @@ function isRemoteHead(r: RawRef): boolean {
return r.isRemote && (!r.shortName.includes('/') || r.shortName.endsWith('/HEAD') || r.refname.endsWith('/HEAD'));
}
+/** PR 状态 → 云图标着色(渲染策略归视图层,状态语义归引擎,同 CI 范式);Draft 弱化前景与 Open 区分。 */
+function prThemeColor(pr: PullRequestVM): vscode.ThemeColor {
+ if (pr.state === 'MERGED') {
+ return new vscode.ThemeColor('charts.purple');
+ }
+ if (pr.state === 'CLOSED') {
+ return new vscode.ThemeColor('charts.red');
+ }
+ if (pr.isDraft) {
+ return new vscode.ThemeColor('descriptionForeground');
+ }
+ return new vscode.ThemeColor('charts.green');
+}
+
/**
* Branches 视图 TreeDataProvider。
*
@@ -54,6 +72,10 @@ function isRemoteHead(r: RawRef): boolean {
* 为空(首帧未填充 / 仓库初始化竞态)时经 CLI 通道 `git for-each-ref` 兜底(含 ahead/behind track)。
* 四段分组:Favorites(收藏置顶,参考 JetBrains 分组设计)/ Local / Remote / Tags。
* 构造函数自订阅 service.onDidChange + favorites.onDidChange 做即时刷新。
+ *
+ * PR 徽标:远程分支行右侧 description 显示关联 PR(`#138 Merged`,GitHub 图标着色)。
+ * 数据经 {@link GitHubPrService} 异步拉取(TTL 缓存):首轮无徽标先行渲染,索引就绪且与快照
+ * 不同才 fire 一次二次渲染(引用相等守卫 + 服务端同实例返回 → 无刷新循环)。
*/
export class BranchesTreeProvider implements vscode.TreeDataProvider, vscode.Disposable {
private readonly _onDidChange = new vscode.EventEmitter();
@@ -64,17 +86,32 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
private groupingKey: string;
/** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */
private groupingEnabled: boolean;
+ /** PR 匹配索引快照(null = 无数据:未登录/非 GitHub/未就绪 → 行为与无徽标一致)。 */
+ private prIndex: RemotePrIndex | null = null;
+ private prEnsureInFlight: Promise | undefined;
+ /** PR 取数代次:切库/登录态/配置任一变化即 +1;在途取数完成时代次已变则弃结果并对当前态补拉。 */
+ private prKickEpoch = 0;
constructor(
private readonly service: GitRepositoryService,
private readonly favorites: BranchFavorites,
private readonly workspaceState: vscode.Memento,
+ private readonly prService: GitHubPrService,
repoRoot: string,
) {
this.groupingKey = `hyperGit.branchesGrouping:${repoRoot}`;
this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true;
this.disposables.push(service.onDidChange(() => this.refresh()));
this.disposables.push(favorites.onDidChange(() => this.refresh()));
+ this.disposables.push(
+ // PR 取数失效(登录/登出、PR 开关、取数源配置——依赖清单由服务单一维护):清快照重取
+ // (登出即隐去徽标;登录后与取数仓切换后无需等待 git 事件)。
+ this.prService.onDidInvalidate(() => {
+ this.prIndex = null;
+ this.prKickEpoch++;
+ this.refresh();
+ }),
+ );
}
/**
@@ -88,6 +125,8 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
}
this.groupingKey = key;
this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true;
+ this.prIndex = null; // 服务端为携带 repoRoot 校验的单槽缓存,切库即失效重取
+ this.prKickEpoch++;
this.refresh();
}
@@ -122,6 +161,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
// displayRefs:隐藏远程 HEAD 符号引用(origin/HEAD),供计数 / 收藏 / 分组一致取用。
const refs = (await this.loadRefs()).filter((r) => !isRemoteHead(r));
if (!element) {
+ this.ensurePrData(); // 异步 kick:索引就绪后 fire 一次二次渲染(首轮无徽标先行)
const byName = new Map(refs.map((r) => [r.shortName, r] as const));
const favCount = this.favorites.list().filter((n) => byName.has(n)).length;
const localCount = refs.filter((r) => !r.isRemote && !r.isTag).length;
@@ -270,6 +310,46 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
}
}
+ /**
+ * 异步取 PR 索引(fire-and-forget,不阻塞建树):索引就绪且与快照不同才 fire 一次;
+ * 结果为 null 且快照非空(如登出)时清快照 fire 隐去徽标(快照已空则不再 fire,无循环)。
+ * 终止性:服务端 TTL 命中与数据未变均返回同一实例 → 引用相等守卫不再 fire;切库/登录态/配置
+ * 变化统一推进 prKickEpoch,在途取数完成时代次已变则弃结果并由链尾按当前态补拉(事件用户驱动,有界)。
+ * 服务自身保证不抛(此处 catch 纯防御)。
+ */
+ private ensurePrData(): void {
+ if (this.prEnsureInFlight) {
+ return;
+ }
+ const epoch = this.prKickEpoch;
+ this.prEnsureInFlight = this.prService
+ .getPullRequests()
+ .then((index) => {
+ if (this.prKickEpoch !== epoch) {
+ return; // 在途期间切库/登录态/配置已变:弃结果(防串仓/旧会话),链尾按当前态补拉
+ }
+ if (index && index !== this.prIndex) {
+ this.prIndex = index;
+ this._onDidChange.fire(undefined);
+ } else if (!index && this.prIndex !== null) {
+ this.prIndex = null;
+ this._onDidChange.fire(undefined);
+ }
+ })
+ .catch(() => {
+ // 服务已保证静默降级;此处仅防未预期异常逃逸刷屏。
+ })
+ .finally(() => {
+ this.prEnsureInFlight = undefined;
+ })
+ .then(() => {
+ // 在途期间代次已变(切库/登录/配置)则按当前态补拉一次(用户驱动,有界不循环)。
+ if (this.prKickEpoch !== epoch) {
+ this.ensurePrData();
+ }
+ });
+ }
+
getTreeItem(element: BranchNode): vscode.TreeItem {
if (element.kind === 'group') {
const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded);
@@ -298,23 +378,28 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
const active = this.isActive(ref);
const fav = this.favorites.isFavorite(ref.shortName);
const isTag = ref.isTag;
+ // PR 徽标仅远程分支(Favorites 里的远程条目同路径自动生效)。
+ const pr = !isTag && element.remote ? this.prIndex?.lookup(ref.shortName) : undefined;
// 分组形态下展示后缀(element.label);平铺/收藏回退完整短名。命令仍以 ref.shortName 定位。
const item = new vscode.TreeItem(element.label ?? ref.shortName, vscode.TreeItemCollapsibleState.None);
- item.description = this.describe(ref, active);
+ item.description = this.describe(ref, active, pr);
item.contextValue = isTag ? 'hyperGit.tag' : element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch';
- item.tooltip = this.tooltip(ref, active, fav);
+ item.tooltip = this.tooltip(ref, active, fav, pr);
const icon = isTag ? 'tag' : element.remote ? 'cloud' : 'git-branch';
- // 活动分支 = charts.blue(与 Log 本地分支 chip 同语义,全局一致);收藏(非活动)= charts.yellow。
+ // 活动分支 = charts.blue(与 Log 本地分支 chip 同语义,全局一致);收藏(非活动)= charts.yellow;
+ // 其余远程分支带 PR → 按状态着色(远程行 active 恒 false,实际即收藏优先于 PR 色)。
const color = active
? new vscode.ThemeColor('charts.blue')
: fav
? new vscode.ThemeColor('charts.yellow')
- : undefined;
+ : pr
+ ? prThemeColor(pr)
+ : undefined;
item.iconPath = new vscode.ThemeIcon(icon, color);
return item;
}
- private describe(ref: RawRef, active: boolean): string {
+ private describe(ref: RawRef, active: boolean, pr?: PullRequestVM): string {
const parts: string[] = [];
if (active) {
parts.push('active');
@@ -326,16 +411,25 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
parts.push(`↓${ref.behind}`);
}
}
+ if (pr) {
+ parts.push(prBadge(pr)); // `#138 Merged` —— 远程行右侧 PR 徽标
+ }
return parts.join(' ');
}
- private tooltip(ref: RawRef, active: boolean, fav: boolean): vscode.MarkdownString {
+ private tooltip(ref: RawRef, active: boolean, fav: boolean, pr?: PullRequestVM): vscode.MarkdownString {
const rows: Array<[string, string]> = [];
if (active) {
rows.push(['State', 'Current branch (HEAD)']);
} else if (fav) {
rows.push(['State', 'Favorite']);
}
+ if (pr) {
+ rows.push(['Pull Request', `#${pr.number} ${prStateLabel(pr)}`]);
+ if (pr.title) {
+ rows.push(['Title', pr.title]);
+ }
+ }
if (ref.objectname) {
rows.push([ref.isTag ? 'Tag' : 'Commit', ref.objectname]);
}
@@ -349,7 +443,12 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider
}
rows.push(['Upstream', `${ref.upstream}${track.length ? ` (${track.join(', ')})` : ''}`]);
}
- return mdTooltip(rows, { title: ref.shortName });
+ // 链接行经服务端主机白名单校验(反 SSRF)后才入 Tooltip;措辞与功能文档同口径(Open on GitHub)。
+ const safeUrl = pr ? this.prService.safePrUrl(pr.url) : null;
+ return mdTooltip(rows, {
+ title: ref.shortName,
+ links: safeUrl ? [{ label: 'Open on GitHub', text: `#${pr?.number}`, href: safeUrl }] : undefined,
+ });
}
/** 判定分支是否当前 HEAD:CLI 解析的 head 标记优先,API 路径回退与 state.HEAD.name 比较。 */
diff --git a/src/adapter/tree/tree-tooltip.ts b/src/adapter/tree/tree-tooltip.ts
index a0776c8..cd80e55 100644
--- a/src/adapter/tree/tree-tooltip.ts
+++ b/src/adapter/tree/tree-tooltip.ts
@@ -6,10 +6,28 @@ import { formatRelative } from '../../engine/log/format-time';
*
* 设计:可选标题行 + 一组 `**label:** value` 行;value 按是否含路径/分隔符自动 code-fence(提升 ref/路径可读性)。
* 取代各树 `\n` 拼接的纯文本 tooltip,统一粗体标签 + code 样式,深/浅主题自适应。
+ * 可选 `links` 追加可点击链接行(如「Open on GitHub」)——仅含链接行的实例放开 isTrusted,
+ * href 由调用方先行主机白名单校验(反 SSRF),此处再做括号消毒防逃逸。
+ *
+ * 注入防线(行值可能承载远程可控文本,如 PR 标题):值统一单行化并反斜杠转义 `[]()`;
+ * code-fence 路径按值内最长反引号串加长 fence(code span 内反斜杠转义无效,必须按长度压制);
+ * isTrusted 采用 `{ enabledCommands: [] }` 白名单形态——http(s) 链接可点、command: 链接一律失效;
+ * supportThemeIcons 恒关(无调用方在 tooltip 文本用 `$(icon)`,关闭可杜绝远程文本注入主题图标字形)。
*/
-export function mdTooltip(rows: ReadonlyArray, opts?: { title?: string }): vscode.MarkdownString {
- // 构造器第二参即 supportThemeIcons(无冗余属性赋值);isTrusted 缺省 false(安全默认)。
- const md = new vscode.MarkdownString('', true);
+
+/** 可点击链接行(label 粗体前缀 + 链接文本 + href)。 */
+export interface TooltipLink {
+ readonly label: string;
+ readonly text: string;
+ readonly href: string;
+}
+
+export function mdTooltip(
+ rows: ReadonlyArray,
+ opts?: { title?: string; links?: readonly TooltipLink[] },
+): vscode.MarkdownString {
+ // supportThemeIcons 缺省 false(安全默认:无调用方用 `$(icon)`,且杜绝远程文本注入图标字形)。
+ const md = new vscode.MarkdownString('');
if (opts?.title) {
md.appendMarkdown(`**${escapeMd(opts.title)}**\n\n`);
}
@@ -18,14 +36,44 @@ export function mdTooltip(rows: ReadonlyArray, opts?:
continue;
}
const isPathLike = /[/.]/.test(value); // 路径或 ref → code 包裹
- md.appendMarkdown(`**${escapeMd(label)}:** ${isPathLike ? '`' + escapeMd(value) + '`' : escapeMd(value)} \n`);
+ md.appendMarkdown(`**${escapeMd(label)}:** ${isPathLike ? codeSpan(value) : escapeMd(value)} \n`);
+ }
+ const links = opts?.links ?? [];
+ if (links.length > 0) {
+ // http 链接在树 Tooltip 可点击需 isTrusted;取 enabledCommands 空白名单形态——
+ // http(s) 可点、command: 链接一律失效(行值含远程可控文本,防命令注入)。
+ md.isTrusted = { enabledCommands: [] };
+ for (const l of links) {
+ md.appendMarkdown(`**${escapeMd(l.label)}:** [${escapeMd(l.text)}](${sanitizeHref(l.href)}) \n`);
+ }
}
return md;
}
-/** MarkdownString 转义:避免反引号/管道/反斜杠破坏 code-fence 或表格语义。 */
+/** 行值单行化:换行折叠为空格(tooltip 行按单行呈现,也消除软换行后的语法注入面)。 */
+function singleLine(s: string): string {
+ return s.replace(/[\r\n]+/g, ' ');
+}
+
+/** MarkdownString 转义:单行化 + 反斜杠转义反引号/管道/反斜杠、`[]()`(防链接/图片语法注入)
+ * 与 `<>`(防 CommonMark 角括号 autolink 在 isTrusted 实例中生成非白名单可点链接)。 */
function escapeMd(s: string): string {
- return s.replace(/([\\`|])/g, '\\$1');
+ return singleLine(s).replace(/([\\`|[\]()<>])/g, '\\$1');
+}
+
+/** 任意值安全入 code span:fence 长于值内最长反引号串(code span 内反斜杠转义无效,须按长度压制)。 */
+function codeSpan(value: string): string {
+ const v = singleLine(value);
+ const maxRun = v.match(/`+/g)?.reduce((m, r) => Math.max(m, r.length), 0) ?? 0;
+ const fence = '`'.repeat(maxRun + 1);
+ // 首尾为反引号时补空格垫层,避免 fence 与内容粘连(CommonMark 等长闭合扫描)。
+ const pad = v.startsWith('`') || v.endsWith('`') ? ' ' : '';
+ return fence + pad + v + pad + fence;
+}
+
+/** 链接 href 消毒:先对右括号百分号编码(纵深防御,防提前闭合 `[text](href)` 语法),再经 escapeMd 转义。 */
+function sanitizeHref(href: string): string {
+ return escapeMd(href.replace(/\)/g, '%29'));
}
/**
diff --git a/src/engine/ci/model.ts b/src/engine/ci/model.ts
index 9f7d012..6eaa26d 100644
--- a/src/engine/ci/model.ts
+++ b/src/engine/ci/model.ts
@@ -36,6 +36,20 @@ export function extractRateLimit(json: unknown): RateLimitInfo | null {
};
}
+/** Retry-After 头 → 毫秒(delta-seconds 或 HTTP-date 形态;缺失/非法/负值 → null)。
+ * 单一事实源供 CI / PR 两服务的 403 冷却共用,杜绝 `Number(header)` 非法值产出 NaN 毒化冷却。 */
+export function parseRetryAfterMs(header: string | null): number | null {
+ if (!header) {
+ return null;
+ }
+ const secs = Number(header);
+ if (Number.isFinite(secs)) {
+ return secs >= 0 ? secs * 1000 : null;
+ }
+ const at = Date.parse(header); // HTTP-date 形态(如 Fri, 31 Dec 1999 23:59:59 GMT)
+ return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
+}
+
type RawNode = Record | null | undefined;
/** 单个 context(CheckRun 或 StatusContext)归一为 {@link CiCheckVM};不可识别返回 null。 */
diff --git a/src/engine/ci/remote-parser.ts b/src/engine/ci/remote-parser.ts
index 75bd293..89dc769 100644
--- a/src/engine/ci/remote-parser.ts
+++ b/src/engine/ci/remote-parser.ts
@@ -57,7 +57,15 @@ export function parseGitHubRemote(input: string): GitHubRemote | null {
path = scp[1];
}
- let p = decodeURIComponent(path).replace(/^\/+/, '').replace(/\/+$/, '');
+ // decode 容错:路径含非法百分号序列(如 `100%`、`%ZZ`)时 decodeURIComponent 抛 URIError,
+ // 视同不可解析形态返回 null(兑现本函数契约;fetchAll 对全部 remotes 逐个调用,不容抛错)。
+ let p: string;
+ try {
+ p = decodeURIComponent(path);
+ } catch {
+ return null;
+ }
+ p = p.replace(/^\/+/, '').replace(/\/+$/, '');
if (/\.git$/i.test(p)) {
p = p.slice(0, -4);
}
@@ -98,3 +106,35 @@ export function authProviderId(remote: GitHubRemote): 'github' | 'github-enterpr
export function commitWebUrl(remote: GitHubRemote, hash: string): string {
return `https://${remote.host}/${remote.owner}/${remote.repo}/commit/${hash}`;
}
+
+/**
+ * GitHub 主机白名单判定(单一事实源,CI openExternal 与 Branches PR safePrUrl 共用)。
+ * host 传**不含端口/userinfo** 的主机名;github.com 及其子域恒放行,GHE 按仓库远程主机
+ * (含 `api.` 前缀的 API 域)放行。统一为 hostname 语义,消除两调用方端口/userinfo 口径漂移。
+ */
+export function isAllowedGitHubHost(host: string, remoteHost?: string): boolean {
+ const h = host.toLowerCase();
+ const rh = remoteHost?.toLowerCase();
+ return h === 'github.com' || h.endsWith('.github.com') || (!!rh && (h === rh || h === `api.${rh}`));
+}
+
+/**
+ * 该主机是否为可取数的已知 GitHub 实例(单一事实源,CI 与 Branches PR 两个服务共用):
+ * github.com,或与已配置 `github-enterprise.uri` 一致的实例主机。`parseGitHubRemote` 只辨
+ * 形态不辨主机(机制/策略分离)——「是否把 token 发往该主机」的策略由此谓词落地:其余
+ * 「GitHub 形态」远程(如 origin 指向任意第三方主机)视同非 GitHub(零请求)。
+ */
+export function isGitHubInstanceHost(host: string, enterpriseUri?: string): boolean {
+ if (host.toLowerCase() === 'github.com') {
+ return true;
+ }
+ if (!enterpriseUri) {
+ return false;
+ }
+ try {
+ const withScheme = /^https?:\/\//i.test(enterpriseUri) ? enterpriseUri : `https://${enterpriseUri}`;
+ return new URL(withScheme).hostname.toLowerCase() === host.toLowerCase();
+ } catch {
+ return false;
+ }
+}
diff --git a/src/engine/pr/graphql-query.ts b/src/engine/pr/graphql-query.ts
new file mode 100644
index 0000000..6cb1f6e
--- /dev/null
+++ b/src/engine/pr/graphql-query.ts
@@ -0,0 +1,44 @@
+/**
+ * Pull Request 列表查询的 GraphQL 构造(纯逻辑,零 vscode/网络依赖)。
+ *
+ * 按 UPDATED_AT 倒序拉取 OPEN/MERGED/CLOSED 全态 PR——分支徽标关心的是「每条远程分支
+ * 最近的一条 PR」,而非某单一状态。`owner/name/after` 一律走 `$variables` 不插值(防注入,
+ * 镜像 `engine/ci/graphql-query.ts`);`first` 经整数区间校验后插值(GitHub 单页上限 100)。
+ * 附带 `rateLimit{cost remaining resetAt}` 供 adapter 自适应限流冷却。
+ */
+
+/** buildPrQuery 入参。 */
+export interface PrQueryInput {
+ /** 单页条数(GitHub pullRequests 连接的 first 上限为 100)。 */
+ readonly first: number;
+ /** 分页游标(首页省略 / 传 null)。 */
+ readonly after?: string | null;
+}
+
+/** buildPrQuery 产出。 */
+export interface PrQueryOutput {
+ readonly query: string;
+}
+
+/**
+ * 构造 PR 列表查询文档。first 非整数 / ≤0 / >100、after 为空串时抛错(防御,杜绝畸形查询)。
+ */
+export function buildPrQuery(input: PrQueryInput): PrQueryOutput {
+ const first = input.first;
+ if (!Number.isInteger(first) || first <= 0 || first > 100) {
+ throw new Error(`first 非法(须为 1~100 的整数):${first}`);
+ }
+ if (input.after !== undefined && input.after !== null && input.after === '') {
+ throw new Error('after 为空串(首页应省略或传 null)');
+ }
+ const query = `query BranchPullRequests($owner: String!, $name: String!, $after: String) {
+ rateLimit { cost remaining resetAt }
+ repository(owner: $owner, name: $name) {
+ pullRequests(first: ${first}, states: [OPEN, MERGED, CLOSED], orderBy: {field: UPDATED_AT, direction: DESC}, after: $after) {
+ pageInfo { hasNextPage endCursor }
+ nodes { number state isDraft title url updatedAt headRefName headRepositoryOwner { login } headRepository { name } }
+ }
+ }
+}`;
+ return { query };
+}
diff --git a/src/engine/pr/match.ts b/src/engine/pr/match.ts
new file mode 100644
index 0000000..b343bae
--- /dev/null
+++ b/src/engine/pr/match.ts
@@ -0,0 +1,133 @@
+/**
+ * 远程分支 → Pull Request 匹配(纯逻辑,零 vscode 依赖)。
+ *
+ * 匹配对象是「剥掉 remote 前缀后的分支路径」与 PR 的 `headRefName`(head 仓库内的分支名)。
+ * 前缀剥离复用 {@link resolveRemoteBranch} 的最长前缀匹配(remote 名可含 `/`)。三级优先:
+ * - **a(fork 全名 + remote 身份校验)**:shortName 恰为 `${headOwner}/${headRefName}`,且首段
+ * 是真实存在的 remote、其 URL 解析 host/owner/repo 与本仓 host 及 PR 的 head owner/head repo
+ * 一致(owner/repo 大小写不敏感、host 与本仓同实例;fork 惯例保留 base 仓库名,同 host+owner+repo
+ * 即本仓 fork)——remote 本身以 fork owner 命名(如 `mylogin/feature`)的场景,无需剥离即精确命中;
+ * 身份校验排除 login 与 remote 名撞名(如贡献者 login 恰为 `origin`)、含 `/` remote 名与
+ * owner/branch 键同形、remote 指向同/异 owner 他仓他实例的错误命中;
+ * - **b(同仓)**:剥离后 `branch === headRefName && headOwner === repoOwner`——dependabot /
+ * 仓内功能分支的主场景;
+ * - **c(唯一兜底)**:剥离后 `branch === headRefName` 且候选唯一;歧义(多 fork 同名分支)→
+ * 不匹配(宁缺勿错,绝不显示错误 PR)。
+ *
+ * owner 比较大小写不敏感(GitHub owner 查询不敏感、login 返回规范形,而远程 URL 侧原样保留),
+ * 分支名保持大小写敏感。
+ *
+ * 同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。
+ */
+
+import { resolveRemoteBranch } from '../ref/remote-ref';
+import type { PullRequestVM } from './types';
+
+/** Tier a 身份校验用的 remote 描述(name + URL 解析出的 host/owner/repo;null = 不可解析形态)。 */
+export interface PrRemoteInfo {
+ readonly name: string;
+ readonly host: string | null;
+ readonly owner: string | null;
+ readonly repo: string | null;
+}
+
+/** buildPrIndex 入参(构建期快照)。 */
+export interface PrIndexInit {
+ /** 当前仓库 owner(Tier b 同仓判定基准;owner 比较大小写不敏感——URL 形与规范 login 可不同)。 */
+ readonly repoOwner: string;
+ /** 当前仓库主机(小写;fork 与 base 同实例,Tier a 要求 remote host 与之一致,排除跨 forge 撞名)。 */
+ readonly repoHost: string;
+ /** 已知 remote 列表(name + URL 解析 host/owner/repo;最长前缀剥离与 Tier a 身份校验共用,事实源 `repo.state.remotes`)。 */
+ readonly remotes: readonly PrRemoteInfo[];
+}
+
+/** 预计算索引;Branches 树逐行 O(1) 查询。 */
+export interface RemotePrIndex {
+ lookup(shortName: string): PullRequestVM | undefined;
+}
+
+/** `updatedAt` 近者胜(解析失败按 -∞ 处理,恒不优于合法值)。 */
+function newer(a: PullRequestVM, b: PullRequestVM): PullRequestVM {
+ const ta = Date.parse(a.updatedAt);
+ const tb = Date.parse(b.updatedAt);
+ return (Number.isNaN(ta) ? -Infinity : ta) >= (Number.isNaN(tb) ? -Infinity : tb) ? a : b;
+}
+
+/** 构建索引:三个 Map 预计算,lookup 侧零遍历。owner 段统一折叠小写(GitHub owner 大小写不敏感,
+ * `headRepositoryOwner.login` 返回规范形而远程 URL 侧原样),`headRefName`/branch 保持大小写敏感。 */
+export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): RemotePrIndex {
+ // Tier a:`owner/branch` 全名(headOwner 缺失的 PR 不参与;owner 段小写键)。
+ const byForkFullName = new Map();
+ // Tier b:同仓 PR 按 headRefName。
+ const bySameRepoBranch = new Map();
+ // Tier c 兜底:全部 PR 按 headRefName(唯一才采用)。
+ const byBranchAll = new Map();
+ // Tier b/c 最长前缀剥离用纯名列表;Tier a 身份校验用 remote 名 → URL host/owner/repo(owner/repo 双侧小写)。
+ // 身份表按 remote 名**精确大小写**取键(git remote 名大小写敏感,折叠会使互异名坍缩、身份张冠李戴)。
+ const remoteNames = init.remotes.map((r) => r.name);
+ const repoHost = init.repoHost.toLowerCase();
+ const remoteIdentity = new Map(
+ init.remotes.map((r) =>
+ [r.name, !r.host || !r.owner || !r.repo ? null : { host: r.host.toLowerCase(), owner: r.owner.toLowerCase(), repo: r.repo.toLowerCase() }] as const,
+ ),
+ );
+ const repoOwner = init.repoOwner.toLowerCase();
+ for (const pr of prs) {
+ if (pr.headOwner) {
+ const full = `${pr.headOwner.toLowerCase()}/${pr.headRefName}`;
+ byForkFullName.set(full, byForkFullName.has(full) ? newer(byForkFullName.get(full)!, pr) : pr);
+ }
+ if (pr.headOwner.toLowerCase() === repoOwner) {
+ bySameRepoBranch.set(pr.headRefName, bySameRepoBranch.has(pr.headRefName) ? newer(bySameRepoBranch.get(pr.headRefName)!, pr) : pr);
+ }
+ const list = byBranchAll.get(pr.headRefName);
+ if (list) {
+ list.push(pr);
+ } else {
+ byBranchAll.set(pr.headRefName, [pr]);
+ }
+ }
+ return {
+ lookup(shortName: string): PullRequestVM | undefined {
+ // Tier a:fork 全名精确命中(首段即 owner,折叠小写后查键)+ 身份校验——首段必须是真实
+ // 存在的可解析 remote,且其 URL 解析 host 与本仓同实例、owner/repo 与命中 PR 的 head
+ // owner/head repo 双侧小写一致(防撞名与异仓劫持,见模块头注;headRepo 缺失 → '' 恒不匹配,宁缺勿错)。
+ const slash = shortName.indexOf('/');
+ if (slash >= 0) {
+ const seg = shortName.slice(0, slash);
+ const forkHit = byForkFullName.get(seg.toLowerCase() + shortName.slice(slash)); // owner 段折叠小写查 fork 键
+ const identity = remoteIdentity.get(seg); // remote 名精确大小写查身份表
+ if (
+ forkHit &&
+ identity &&
+ identity.host === repoHost &&
+ identity.owner === forkHit.headOwner.toLowerCase() &&
+ identity.repo === forkHit.headRepo.toLowerCase()
+ ) {
+ return forkHit;
+ }
+ }
+ // 剥离 remote 前缀(最长前缀;纯 remote 名 / 未知 remote → null → 不匹配)。
+ const target = resolveRemoteBranch(shortName, remoteNames);
+ if (!target) {
+ return undefined;
+ }
+ // Tier b:同仓 PR。
+ const sameRepo = bySameRepoBranch.get(target.branch);
+ if (sameRepo) {
+ return sameRepo;
+ }
+ // Tier c:全量候选唯一才采用;歧义宁缺。
+ const all = byBranchAll.get(target.branch);
+ if (!all || all.length !== 1) {
+ return undefined;
+ }
+ return all[0];
+ },
+ };
+}
+
+/** 单发便捷形态(= buildPrIndex(prs, init).lookup(shortName)),主供测试与零星调用。 */
+export function matchRemoteBranchPr(shortName: string, init: PrIndexInit, prs: readonly PullRequestVM[]): PullRequestVM | undefined {
+ return buildPrIndex(prs, init).lookup(shortName);
+}
diff --git a/src/engine/pr/model.ts b/src/engine/pr/model.ts
new file mode 100644
index 0000000..04351b4
--- /dev/null
+++ b/src/engine/pr/model.ts
@@ -0,0 +1,121 @@
+/**
+ * GraphQL 响应 → {@link PullRequestVM} 解析(纯逻辑,零 vscode/网络依赖)。
+ *
+ * 响应形态宽容(镜像 `engine/ci/model.ts`):节点级异常(非法 state / 非正数 number)丢弃该
+ * 节点不中断整批;缺 `data`/`repository`/`pullRequests`(含 errors-only 响应)整体返回 null,
+ * 调用方放弃本次且不缓存,留待重试。
+ */
+
+import type { PrState, PullRequestVM } from './types';
+
+/** 单页解析结果。 */
+export interface PrPage {
+ readonly prs: readonly PullRequestVM[];
+ readonly hasNextPage: boolean;
+ readonly endCursor: string | null;
+}
+
+const KNOWN_STATES: readonly PrState[] = ['OPEN', 'MERGED', 'CLOSED'];
+
+type RawNode = Record | null | undefined;
+
+/** 字段级字符串守卫:非字符串(含缺失)→ '',杜绝盲转型把 number/boolean/object 放进 VM。 */
+function asString(v: unknown): string {
+ return typeof v === 'string' ? v : '';
+}
+
+/** 单节点 → {@link PullRequestVM};state 未知 / number 非正数 → null(丢弃)。 */
+function parseNode(node: RawNode): PullRequestVM | null {
+ if (!node || typeof node !== 'object') {
+ return null;
+ }
+ const state = node.state as string | null | undefined;
+ if (typeof state !== 'string' || !KNOWN_STATES.includes(state as PrState)) {
+ return null;
+ }
+ const number_ = node.number;
+ if (typeof number_ !== 'number' || !Number.isInteger(number_) || number_ <= 0) {
+ return null;
+ }
+ const owner = (node.headRepositoryOwner as { login?: unknown } | null | undefined)?.login;
+ const repo = (node.headRepository as { name?: unknown } | null | undefined)?.name;
+ return {
+ number: number_,
+ state: state as PrState,
+ isDraft: node.isDraft === true,
+ title: asString(node.title),
+ url: asString(node.url),
+ updatedAt: asString(node.updatedAt),
+ headRefName: asString(node.headRefName),
+ // fork 元数据可能为 null(如 head 仓库被删除)→ '',不参与 fork 全名匹配。
+ headOwner: typeof owner === 'string' ? owner : '',
+ headRepo: asString(repo),
+ };
+}
+
+/**
+ * 解析整页响应。缺 `data`/`repository`/`pullRequests` → null(不缓存,留待重试);
+ * `nodes: null` / 非数组 → 空页(合法:仓库无 PR / 宽容降级)。
+ */
+export function parsePullRequestsResponse(json: unknown): PrPage | null {
+ const conn = (json as { data?: { repository?: { pullRequests?: RawConn } } })?.data?.repository?.pullRequests;
+ if (!conn) {
+ return null;
+ }
+ const rawNodes = (conn as RawConn).nodes;
+ const nodes = Array.isArray(rawNodes) ? rawNodes : [];
+ const prs = nodes.map((n) => parseNode(n)).filter((p): p is PullRequestVM => p !== null);
+ const pageInfo = (conn as RawConn).pageInfo;
+ return {
+ prs,
+ hasNextPage: pageInfo?.hasNextPage === true,
+ endCursor: typeof pageInfo?.endCursor === 'string' ? pageInfo.endCursor : null,
+ };
+}
+
+interface RawConn {
+ nodes?: RawNode[] | null;
+ pageInfo?: { hasNextPage?: unknown; endCursor?: unknown } | null;
+}
+
+/** OPEN+draft → Draft;OPEN → Open;MERGED/CLOSED 原词。 */
+export function prStateLabel(pr: Pick): string {
+ if (pr.state === 'OPEN' && pr.isDraft) {
+ return 'Draft';
+ }
+ const map: Record = { OPEN: 'Open', MERGED: 'Merged', CLOSED: 'Closed' };
+ return map[pr.state];
+}
+
+/** Branches 行内描述徽标:`#138 Merged` 形态。 */
+export function prBadge(pr: PullRequestVM): string {
+ return `#${pr.number} ${prStateLabel(pr)}`;
+}
+
+/** 深比较(TTL 重取后数据未变 → 服务层续期并返回同一索引实例,抑制无效 fire)。 */
+export function prsEqual(a: readonly PullRequestVM[], b: readonly PullRequestVM[]): boolean {
+ if (a === b) {
+ return true;
+ }
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i];
+ const y = b[i];
+ if (
+ x.number !== y.number ||
+ x.state !== y.state ||
+ x.isDraft !== y.isDraft ||
+ x.title !== y.title ||
+ x.url !== y.url ||
+ x.updatedAt !== y.updatedAt ||
+ x.headRefName !== y.headRefName ||
+ x.headOwner !== y.headOwner ||
+ x.headRepo !== y.headRepo
+ ) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/src/engine/pr/types.ts b/src/engine/pr/types.ts
new file mode 100644
index 0000000..1edd795
--- /dev/null
+++ b/src/engine/pr/types.ts
@@ -0,0 +1,28 @@
+/**
+ * Pull Request 视图模型(engine 纯逻辑,零 vscode 依赖)。
+ *
+ * 状态与展示解耦(同 `engine/ci/types.ts` 的 CiStatusVM 范式):本模块只承载 GitHub
+ * PullRequestState 的归一事实;图标/配色/文案由渲染层(branches-tree)依据 state/isDraft 决定。
+ */
+
+/** GitHub PullRequestState 原样透传;未知值在解析端丢弃(`model.ts`)。 */
+export type PrState = 'OPEN' | 'MERGED' | 'CLOSED';
+
+/** 单条 PR 的视图模型。 */
+export interface PullRequestVM {
+ readonly number: number;
+ readonly state: PrState;
+ /** 草稿(state=OPEN 且 isDraft)→ 展示层弱化配色与 Draft 文案。 */
+ readonly isDraft: boolean;
+ readonly title: string;
+ /** 浏览器端 PR 页链接(适配层经主机白名单校验后才入 Tooltip)。 */
+ readonly url: string;
+ /** ISO 8601(GitHub 原样);同一 headRefName 多条 PR 时按此取最近。 */
+ readonly updatedAt: string;
+ /** head 分支名(head 仓库内,不含 owner 前缀)。 */
+ readonly headRefName: string;
+ /** head 仓库 owner login;fork 元数据缺失(null)→ ''(不参与 fork 全名匹配)。 */
+ readonly headOwner: string;
+ /** head 仓库名;fork 惯例保留 base 名(Tier a 身份校验的 repo 维度,缺失 → '')。 */
+ readonly headRepo: string;
+}
diff --git a/src/extension.ts b/src/extension.ts
index f4cc4b3..ea8d1dc 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -33,6 +33,7 @@ import { getGitApi } from './adapter/git-api';
import { GitRepositoryService } from './adapter/git-repository-service';
import { GitHubAuth } from './adapter/ci/github-auth';
import { GitHubCiService } from './adapter/ci/github-ci-service';
+import { GitHubPrService } from './adapter/pr/github-pr-service';
import { createLogger } from './infra/logger';
/** 无 git 时的占位树 provider(空树,触发 viewsWelcome)。原 EmptyChangesProvider 随 Changes 视图移除后内联于此。 */
@@ -104,6 +105,7 @@ export async function activate(
const favorites = new BranchFavorites(context.workspaceState, service.repoRoot ?? workspaceRoot);
const githubAuth = new GitHubAuth(context.subscriptions);
const ciService = new GitHubCiService(service, githubAuth, logger);
+ const prService = new GitHubPrService(service, ciService, githubAuth, logger);
// AI 接缝注入(Null 实现,M5 替换为真实 provider)
const commit = new CommitService(context, service, context.workspaceState, {
@@ -119,6 +121,7 @@ export async function activate(
service,
favorites,
context.workspaceState,
+ prService,
service.repoRoot ?? workspaceRoot,
);
// Branches 视图启用多选(canSelectMany 仅 createTreeView 支持,registerTreeDataProvider 不支持);
@@ -196,6 +199,7 @@ export async function activate(
favorites,
commit,
ciService,
+ prService,
commitView,
logTree,
branchesTree,
diff --git a/tests/unit/ci-model.test.ts b/tests/unit/ci-model.test.ts
index 049fc61..c09626a 100644
--- a/tests/unit/ci-model.test.ts
+++ b/tests/unit/ci-model.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { extractRateLimit, parseCiResponse, UNKNOWN_CI } from '../../src/engine/ci/model';
+import { extractRateLimit, parseCiResponse, parseRetryAfterMs, UNKNOWN_CI } from '../../src/engine/ci/model';
const repo = (entries: Record): unknown => ({ data: { repository: entries } });
const oid = (i: number): string => i.toString(16).padStart(40, '0');
@@ -104,3 +104,23 @@ describe('extractRateLimit', () => {
expect(extractRateLimit({})).toBeNull();
});
});
+
+describe('parseRetryAfterMs', () => {
+ it('delta-seconds 形态 → 毫秒(含 0)', () => {
+ expect(parseRetryAfterMs('60')).toBe(60_000);
+ expect(parseRetryAfterMs('0')).toBe(0);
+ });
+ it('HTTP-date 形态 → 与当前时刻的差值(过去的时刻钳为 0)', () => {
+ const future = new Date(Date.now() + 120_000).toUTCString();
+ const ms = parseRetryAfterMs(future)!;
+ expect(ms).toBeGreaterThanOrEqual(119_000);
+ expect(ms).toBeLessThanOrEqual(120_500);
+ expect(parseRetryAfterMs('Fri, 31 Dec 1999 23:59:59 GMT')).toBe(0);
+ });
+ it('缺失 / 空串 / 非数字 / 负值 → null(杜绝 NaN 毒化冷却)', () => {
+ expect(parseRetryAfterMs(null)).toBeNull();
+ expect(parseRetryAfterMs('')).toBeNull();
+ expect(parseRetryAfterMs('soon')).toBeNull();
+ expect(parseRetryAfterMs('-5')).toBeNull();
+ });
+});
diff --git a/tests/unit/ci-remote-parser.test.ts b/tests/unit/ci-remote-parser.test.ts
index ff83315..b4d2ecd 100644
--- a/tests/unit/ci-remote-parser.test.ts
+++ b/tests/unit/ci-remote-parser.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { authProviderId, graphqlEndpoint, parseGitHubRemote, commitWebUrl } from '../../src/engine/ci/remote-parser';
+import { authProviderId, graphqlEndpoint, isAllowedGitHubHost, isGitHubInstanceHost, parseGitHubRemote, commitWebUrl } from '../../src/engine/ci/remote-parser';
describe('parseGitHubRemote — 合法形态', () => {
it('https(带 .git)', () => {
@@ -54,6 +54,11 @@ describe('parseGitHubRemote — 非法形态返回 null', () => {
it('非 GitHub 风格(纯文件路径)', () => {
expect(parseGitHubRemote('/usr/local/bin')).toBeNull();
});
+ it('路径含非法百分号序列 → null(decode 容错,不抛 URIError)', () => {
+ expect(parseGitHubRemote('https://github.com/o/r%ZZ')).toBeNull();
+ expect(parseGitHubRemote('git@github.com:o/r%')).toBeNull();
+ expect(parseGitHubRemote('https://example.com/100%')).toBeNull();
+ });
});
describe('graphqlEndpoint', () => {
@@ -86,3 +91,44 @@ describe('commitWebUrl', () => {
expect(commitWebUrl(r, 'deadbeef')).toBe('https://ghe.acme.com/o/r/commit/deadbeef');
});
});
+
+describe('isAllowedGitHubHost', () => {
+ it('github.com 及其子域恒放行(大小写归一)', () => {
+ expect(isAllowedGitHubHost('github.com')).toBe(true);
+ expect(isAllowedGitHubHost('www.github.com')).toBe(true);
+ expect(isAllowedGitHubHost('API.GitHub.com')).toBe(true);
+ });
+ it('相似恶意域拒绝', () => {
+ expect(isAllowedGitHubHost('github.com.evil.io')).toBe(false);
+ expect(isAllowedGitHubHost('notgithub.com')).toBe(false);
+ expect(isAllowedGitHubHost('github.community')).toBe(false);
+ });
+ it('GHE 按仓库主机与 api. 前缀放行(双侧大小写归一)', () => {
+ expect(isAllowedGitHubHost('ghe.acme.com', 'ghe.acme.com')).toBe(true);
+ expect(isAllowedGitHubHost('api.ghe.acme.com', 'GHE.ACME.COM')).toBe(true);
+ expect(isAllowedGitHubHost('ghe.other.io', 'ghe.acme.com')).toBe(false);
+ });
+ it('remoteHost 缺省 → 仅 github.com 域放行', () => {
+ expect(isAllowedGitHubHost('github.com', undefined)).toBe(true);
+ expect(isAllowedGitHubHost('ghe.acme.com', undefined)).toBe(false);
+ });
+});
+
+describe('isGitHubInstanceHost', () => {
+ it('github.com 恒可信(大小写归一)', () => {
+ expect(isGitHubInstanceHost('github.com')).toBe(true);
+ expect(isGitHubInstanceHost('GITHUB.COM')).toBe(true);
+ });
+ it('与已配置 GHE uri 一致的实例主机可信(host-only / 全 URL / 大小写互异形态)', () => {
+ expect(isGitHubInstanceHost('ghe.acme.com', 'ghe.acme.com')).toBe(true);
+ expect(isGitHubInstanceHost('ghe.acme.com', 'https://GHE.Acme.com/')).toBe(true);
+ });
+ it('未配置 uri 或主机不一致(含任意第三方「GitHub 形态」主机)→ 不可信', () => {
+ expect(isGitHubInstanceHost('ghe.acme.com', undefined)).toBe(false);
+ expect(isGitHubInstanceHost('ghe.acme.com', 'https://other.example.com')).toBe(false);
+ expect(isGitHubInstanceHost('evil.example', 'ghe.acme.com')).toBe(false);
+ });
+ it('非法 uri 形态 → 不可信(不抛错)', () => {
+ expect(isGitHubInstanceHost('ghe.acme.com', 'http://')).toBe(false);
+ });
+});
diff --git a/tests/unit/pr-graphql-query.test.ts b/tests/unit/pr-graphql-query.test.ts
new file mode 100644
index 0000000..dad3448
--- /dev/null
+++ b/tests/unit/pr-graphql-query.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { buildPrQuery } from '../../src/engine/pr/graphql-query';
+
+describe('buildPrQuery', () => {
+ it('owner/name/after 走 $variables,不插值进文档', () => {
+ const { query } = buildPrQuery({ first: 50 });
+ expect(query).toContain('$owner: String!');
+ expect(query).toContain('$name: String!');
+ expect(query).toContain('$after: String');
+ expect(query).toContain('after: $after');
+ });
+
+ it('first 插值且随入参变化', () => {
+ expect(buildPrQuery({ first: 50 }).query).toContain('pullRequests(first: 50');
+ expect(buildPrQuery({ first: 25 }).query).toContain('pullRequests(first: 25');
+ });
+
+ it('查询结构:全态 + UPDATED_AT 倒序 + pageInfo + 节点字段 + rateLimit', () => {
+ const { query } = buildPrQuery({ first: 50 });
+ expect(query).toContain('states: [OPEN, MERGED, CLOSED]');
+ expect(query).toContain('orderBy: {field: UPDATED_AT, direction: DESC}');
+ expect(query).toContain('pageInfo { hasNextPage endCursor }');
+ expect(query).toContain('number state isDraft title url updatedAt headRefName headRepositoryOwner { login } headRepository { name }');
+ expect(query).toContain('rateLimit { cost remaining resetAt }');
+ });
+
+ it('first 边界抛错(非整数 / ≤0 / >100)', () => {
+ expect(() => buildPrQuery({ first: 0 })).toThrow();
+ expect(() => buildPrQuery({ first: -1 })).toThrow();
+ expect(() => buildPrQuery({ first: 100.5 })).toThrow();
+ expect(() => buildPrQuery({ first: 101 })).toThrow();
+ expect(() => buildPrQuery({ first: Number.NaN })).toThrow();
+ });
+
+ it('首页形态合法:after 省略 / null 均可;空串抛错', () => {
+ expect(() => buildPrQuery({ first: 50 })).not.toThrow();
+ expect(() => buildPrQuery({ first: 50, after: null })).not.toThrow();
+ expect(() => buildPrQuery({ first: 50, after: 'Y3Vyc29y' })).not.toThrow();
+ expect(() => buildPrQuery({ first: 50, after: '' })).toThrow();
+ });
+});
diff --git a/tests/unit/pr-match.test.ts b/tests/unit/pr-match.test.ts
new file mode 100644
index 0000000..b6c9860
--- /dev/null
+++ b/tests/unit/pr-match.test.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect } from 'vitest';
+import { buildPrIndex, matchRemoteBranchPr, type PrRemoteInfo } from '../../src/engine/pr/match';
+import type { PullRequestVM } from '../../src/engine/pr/types';
+
+function pr(overrides: Partial = {}): PullRequestVM {
+ return {
+ number: 1,
+ state: 'OPEN',
+ isDraft: false,
+ title: '',
+ url: '',
+ updatedAt: '2026-01-01T00:00:00Z',
+ headRefName: 'feature',
+ headOwner: 'acme',
+ headRepo: 'app',
+ ...overrides,
+ };
+}
+
+/** remote 夹具:owner 缺省与 name 同值(fork remote 命名场景);repo 缺省 'app'(fork 保留 base 名);host 缺省与本仓同实例。 */
+function rm(name: string, owner: string | null = name, repo: string | null = 'app', host: string | null = 'github.com'): PrRemoteInfo {
+ return { name, host, owner, repo };
+}
+
+const ACME = { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme')] };
+
+describe('buildPrIndex / matchRemoteBranchPr', () => {
+ it('Tier a:remote 以 fork owner 命名,shortName 恰为 owner/branch 全名', () => {
+ const prs = [pr({ headOwner: 'mylogin', headRefName: 'feature', number: 7 })];
+ const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('mylogin')] }, prs);
+ expect(hit?.number).toBe(7);
+ });
+
+ it('Tier b:剥 origin 前缀后同仓 headRefName 精确命中(dependabot 主场景)', () => {
+ const prs = [pr({ headOwner: 'acme', headRefName: 'dependabot/npm_and_yarn/x', number: 138, state: 'MERGED' })];
+ const hit = matchRemoteBranchPr('origin/dependabot/npm_and_yarn/x', ACME, prs);
+ expect(hit?.number).toBe(138);
+ expect(hit?.state).toBe('MERGED');
+ });
+
+ it('Tier a 优先于 Tier b(fork 全名与同仓分支双候选时 fork 全名先命中)', () => {
+ const fork = pr({ headOwner: 'mylogin', headRefName: 'feature', number: 7 });
+ // 同仓 PR 也用 headRefName 'feature':若无 Tier a,剥 mylogin 前缀后 Tier b 会命中 #8 —— 优先级真正被钉住。
+ const sameRepo = pr({ headOwner: 'acme', headRefName: 'feature', number: 8 });
+ const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('mylogin')] }, [fork, sameRepo]);
+ expect(hit?.number).toBe(7);
+ });
+
+ it('Tier a 判别:Tier c 歧义(两 fork 同名)时 remote 以 fork owner 命名仍精确命中', () => {
+ const prs = [
+ pr({ headOwner: 'fork1', headRefName: 'patch-1', number: 10 }),
+ pr({ headOwner: 'fork2', headRefName: 'patch-1', number: 11 }),
+ ];
+ // 若无 Tier a:剥 fork1 前缀后 Tier b 无候选、Tier c 二歧义 → undefined;Tier a 须命中 #10。
+ const hit = matchRemoteBranchPr('fork1/patch-1', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('fork1')] }, prs);
+ expect(hit?.number).toBe(10);
+ });
+
+ it('owner 大小写不敏感:URL 形 repoOwner 与规范形 headOwner 仍走 Tier b', () => {
+ const prs = [pr({ headOwner: 'Acme-Inc', headRefName: 'feature', number: 14 })];
+ const hit = matchRemoteBranchPr('origin/feature', { repoOwner: 'acme-inc', repoHost: 'github.com', remotes: [rm('origin', 'acme')] }, prs);
+ expect(hit?.number).toBe(14);
+ });
+
+ it('Tier a 键 owner 段大小写不敏感(remote 名与规范 login 大小写互异)', () => {
+ const prs = [pr({ headOwner: 'MyLogin', headRefName: 'feature', number: 7 })];
+ // remote 名小写 / login 规范形混合大小写,双向均须命中。
+ expect(matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('mylogin')] }, prs)?.number).toBe(7);
+ expect(matchRemoteBranchPr('MyLogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('MyLogin')] }, prs)?.number).toBe(7);
+ });
+
+ it('Tier c:非同仓 fork PR 且候选唯一 → 兜底命中', () => {
+ const prs = [pr({ headOwner: 'contributor', headRefName: 'patch-1', number: 9 })];
+ const hit = matchRemoteBranchPr('myfork/patch-1', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('myfork')] }, prs);
+ expect(hit?.number).toBe(9);
+ });
+
+ it('Tier c 歧义(两个 fork 同名分支)→ 不匹配(宁缺勿错)', () => {
+ const prs = [
+ pr({ headOwner: 'fork1', headRefName: 'patch-1', number: 10 }),
+ pr({ headOwner: 'fork2', headRefName: 'patch-1', number: 11 }),
+ ];
+ expect(matchRemoteBranchPr('origin/patch-1', ACME, prs)).toBeUndefined();
+ // 对照:同仓 PR 存在时 Tier b 直接命中(歧义仅发生在无同仓候选的兜底层)。
+ const withSameRepo = [...prs, pr({ headOwner: 'acme', headRefName: 'patch-1', number: 12 })];
+ expect(matchRemoteBranchPr('origin/patch-1', ACME, withSameRepo)?.number).toBe(12);
+ });
+
+ it('同 headRefName 多条同仓 PR → updatedAt 最近者胜', () => {
+ const prs = [
+ pr({ number: 10, state: 'MERGED', updatedAt: '2026-01-01T00:00:00Z' }),
+ pr({ number: 20, state: 'OPEN', updatedAt: '2026-06-01T00:00:00Z' }),
+ ];
+ const hit = matchRemoteBranchPr('origin/feature', ACME, prs);
+ expect(hit?.number).toBe(20);
+ });
+
+ it('剥前缀失败(未知 remote / 纯 remote 名 / 本地形态)→ 不匹配', () => {
+ const prs = [pr({ headRefName: 'x' })];
+ expect(matchRemoteBranchPr('upstream/x', ACME, prs)).toBeUndefined();
+ expect(matchRemoteBranchPr('origin', ACME, prs)).toBeUndefined();
+ expect(matchRemoteBranchPr('x', ACME, prs)).toBeUndefined();
+ });
+
+ it('含 / 的 remote 名(myorg/repo)正确剥离后命中 Tier b', () => {
+ const prs = [pr({ headRefName: 'feature', number: 12 })];
+ const hit = matchRemoteBranchPr('myorg/repo/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('myorg/repo')] }, prs);
+ expect(hit?.number).toBe(12);
+ });
+
+ it('空 PR 列表 → 恒不匹配', () => {
+ expect(matchRemoteBranchPr('origin/feature', ACME, [])).toBeUndefined();
+ });
+
+ it('headOwner 为空的 PR 不参与 Tier a', () => {
+ const prs = [pr({ headOwner: '', headRefName: 'feature', number: 13 })];
+ // shortName 恰为 `feature`(本地形态,本就剥离失败)→ 双保险不命中。
+ expect(matchRemoteBranchPr('feature', ACME, prs)).toBeUndefined();
+ // 剥离后走 Tier b/c:headOwner ≠ repoOwner → Tier c 唯一兜底命中(合法)。
+ expect(matchRemoteBranchPr('origin/feature', ACME, prs)?.number).toBe(13);
+ });
+
+ it('buildPrIndex 与单发形态等价(索引可复用)', () => {
+ const prs = [pr({ headRefName: 'a', number: 1 }), pr({ headRefName: 'b', number: 2 })];
+ const idx = buildPrIndex(prs, ACME);
+ expect(idx.lookup('origin/a')?.number).toBe(1);
+ expect(idx.lookup('origin/b')?.number).toBe(2);
+ expect(idx.lookup('origin/c')).toBeUndefined();
+ });
+
+ it('Tier a 身份校验:fork PR headOwner 与 remote 名撞名(非该 remote 的 owner)→ 不劫持,落 Tier b', () => {
+ const prs = [
+ pr({ headOwner: 'origin', headRefName: 'feature', number: 21 }), // fork owner 恰叫 origin
+ pr({ headOwner: 'acme', headRefName: 'feature', number: 22 }), // 同仓正主
+ ];
+ // remote origin → URL owner acme ≠ 'origin':Tier a 不命中,Tier b 给 #22。
+ expect(matchRemoteBranchPr('origin/feature', ACME, prs)?.number).toBe(22);
+ });
+
+ it('Tier a 身份校验:含 / 的 remote 名与 owner/branch 键同形 → 不劫持,按最长前缀剥离', () => {
+ const prs = [
+ pr({ headOwner: 'myorg', headRefName: 'repo/feature', number: 31 }), // fork 键恰为 myorg/repo/feature
+ pr({ headOwner: 'acme', headRefName: 'feature', number: 32 }),
+ ];
+ const init = { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('myorg/repo')] };
+ // 首段 'myorg' 不是 remote 名(remote 是 myorg/repo):Tier a 跳过 → 剥离后 Tier b 命中 #32。
+ expect(matchRemoteBranchPr('myorg/repo/feature', init, prs)?.number).toBe(32);
+ });
+
+ it('Tier a 身份校验:remote URL 的 owner/repo 与 head 规范形大小写互异仍命中(双侧小写比较)', () => {
+ const prs = [pr({ headOwner: 'MyLogin', headRefName: 'feature', number: 7, headRepo: 'App' })];
+ // remote URL 手写小写 mylogin/app vs GraphQL 规范形 MyLogin/App:owner 已有大小写用例,此处钉 repo 维度。
+ const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('mylogin', 'mylogin', 'app')] }, prs);
+ expect(hit?.number).toBe(7);
+ });
+
+ it('Tier a 身份校验:remote 指向异 host(他 forge 实例)→ 不承载 fork 匹配', () => {
+ const prs = [
+ pr({ headOwner: 'fork1', headRefName: 'patch-1', number: 10 }),
+ pr({ headOwner: 'fork2', headRefName: 'patch-1', number: 11 }),
+ ];
+ // remote fork1 → ghe.other.io/fork1/app:owner/repo 同形但 host 与本仓不同实例 → Tier a 跳过,Tier c 二歧义 → undefined。
+ const init = { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('fork1', 'fork1', 'app', 'ghe.other.io')] };
+ expect(matchRemoteBranchPr('fork1/patch-1', init, prs)).toBeUndefined();
+ });
+
+ it('Tier a 身份校验:remote 指向同 owner 的他仓(repo 不一致)→ 不劫持,落 Tier b', () => {
+ const prs = [
+ pr({ headOwner: 'octocat', headRefName: 'main', number: 41 }), // 本仓 fork PR(headRepo=app)
+ pr({ headOwner: 'acme', headRefName: 'main', number: 42 }),
+ ];
+ // remote octocat → octocat/dotfiles:owner 一致但 repo 不一致 → Tier a 跳过,Tier b 给 #42。
+ const init = { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('octocat', 'octocat', 'dotfiles')] };
+ expect(matchRemoteBranchPr('octocat/main', init, prs)?.number).toBe(42);
+ });
+
+ it('Tier a 身份校验:非 GitHub remote(owner null)不承载 fork 匹配', () => {
+ // 两 fork 同名分支(Tier c 歧义):若身份校验缺失,remote mylogin 会经 Tier a 命中 #10。
+ const prs = [
+ pr({ headOwner: 'mylogin', headRefName: 'feature', number: 10 }),
+ pr({ headOwner: 'otherlogin', headRefName: 'feature', number: 11 }),
+ ];
+ expect(matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('mylogin', null, null, null)] }, prs)).toBeUndefined();
+ });
+});
diff --git a/tests/unit/pr-model.test.ts b/tests/unit/pr-model.test.ts
new file mode 100644
index 0000000..c83206c
--- /dev/null
+++ b/tests/unit/pr-model.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect } from 'vitest';
+import { parsePullRequestsResponse, prBadge, prStateLabel, prsEqual } from '../../src/engine/pr/model';
+import type { PullRequestVM } from '../../src/engine/pr/types';
+
+function node(overrides: Record = {}): Record {
+ return {
+ number: 138,
+ state: 'MERGED',
+ isDraft: false,
+ title: 'Bump deps',
+ url: 'https://github.com/o/r/pull/138',
+ updatedAt: '2026-10-01T00:00:00Z',
+ headRefName: 'dependabot/npm_and_yarn/x',
+ headRepositoryOwner: { login: 'o' },
+ headRepository: { name: 'r' },
+ ...overrides,
+ };
+}
+
+function page(nodes: unknown[], pageInfo: Record = { hasNextPage: false, endCursor: null }): unknown {
+ return { data: { repository: { pullRequests: { nodes, pageInfo } } } };
+}
+
+const VM: PullRequestVM = {
+ number: 138,
+ state: 'MERGED',
+ isDraft: false,
+ title: 'Bump deps',
+ url: 'https://github.com/o/r/pull/138',
+ updatedAt: '2026-10-01T00:00:00Z',
+ headRefName: 'dependabot/npm_and_yarn/x',
+ headOwner: 'o',
+ headRepo: 'r',
+};
+
+describe('parsePullRequestsResponse', () => {
+ it('正常路径全字段映射(headOwner 取 login)', () => {
+ const out = parsePullRequestsResponse(page([node()]));
+ expect(out?.prs).toHaveLength(1);
+ expect(out?.prs[0]).toEqual(VM);
+ });
+
+ it('headRepositoryOwner null → headOwner 空串;headRepository null → headRepo 空串', () => {
+ const out = parsePullRequestsResponse(page([node({ headRepositoryOwner: null, headRepository: null })]));
+ expect(out?.prs[0]?.headOwner).toBe('');
+ expect(out?.prs[0]?.headRepo).toBe('');
+ });
+
+ it('非法 state / 非正数 number 的节点被丢弃,其余保留', () => {
+ const out = parsePullRequestsResponse(page([node({ state: 'WEIRD' }), node({ number: 0 }), node({ number: 139 })]));
+ expect(out?.prs).toHaveLength(1);
+ expect(out?.prs[0]?.number).toBe(139);
+ });
+
+ it('字段级非字符串值归空串(title/url/updatedAt/headRefName 守卫,节点不丢弃)', () => {
+ const out = parsePullRequestsResponse(page([node({ title: 42, url: null, updatedAt: true, headRefName: { x: 1 } })]));
+ expect(out?.prs).toHaveLength(1);
+ expect(out?.prs[0]).toMatchObject({ number: 138, title: '', url: '', updatedAt: '', headRefName: '' });
+ });
+
+ it('nodes 数组内 null 元素被丢弃', () => {
+ expect(parsePullRequestsResponse(page([null]))?.prs).toEqual([]);
+ });
+
+ it('连接级 nodes: null / 非数组 / [] → 空页(合法,不抛错)', () => {
+ const nullNodes = { data: { repository: { pullRequests: { nodes: null, pageInfo: { hasNextPage: false, endCursor: null } } } } };
+ expect(parsePullRequestsResponse(nullNodes)?.prs).toEqual([]);
+ const objectNodes = { data: { repository: { pullRequests: { nodes: { '0': node() }, pageInfo: null } } } };
+ expect(parsePullRequestsResponse(objectNodes)?.prs).toEqual([]);
+ const empty = parsePullRequestsResponse(page([]));
+ expect(empty?.prs).toEqual([]);
+ expect(empty?.hasNextPage).toBe(false);
+ expect(empty?.endCursor).toBeNull();
+ });
+
+ it('缺 data / repository / pullRequests → null(不缓存)', () => {
+ expect(parsePullRequestsResponse(undefined)).toBeNull();
+ expect(parsePullRequestsResponse({})).toBeNull();
+ expect(parsePullRequestsResponse({ data: {} })).toBeNull();
+ expect(parsePullRequestsResponse({ data: { repository: {} } })).toBeNull();
+ expect(parsePullRequestsResponse({ errors: [{ message: 'x' }] })).toBeNull();
+ });
+
+ it('pageInfo 透传:hasNextPage + endCursor;endCursor 非法 → null', () => {
+ const out = parsePullRequestsResponse(page([node()], { hasNextPage: true, endCursor: 'abc' }));
+ expect(out?.hasNextPage).toBe(true);
+ expect(out?.endCursor).toBe('abc');
+ const bad = parsePullRequestsResponse(page([node()], { hasNextPage: true, endCursor: 42 }));
+ expect(bad?.endCursor).toBeNull();
+ });
+});
+
+describe('prStateLabel / prBadge', () => {
+ it('四态文案:Open / Draft / Merged / Closed', () => {
+ expect(prStateLabel({ state: 'OPEN', isDraft: false })).toBe('Open');
+ expect(prStateLabel({ state: 'OPEN', isDraft: true })).toBe('Draft');
+ expect(prStateLabel({ state: 'MERGED', isDraft: false })).toBe('Merged');
+ expect(prStateLabel({ state: 'CLOSED', isDraft: false })).toBe('Closed');
+ });
+
+ it('prBadge → `#138 Merged`', () => {
+ expect(prBadge(VM)).toBe('#138 Merged');
+ });
+});
+
+describe('prsEqual', () => {
+ it('同引用 / 全等 / 任一字段差 / 长度差', () => {
+ expect(prsEqual([VM], [VM])).toBe(true);
+ expect(prsEqual([VM], [{ ...VM }])).toBe(true);
+ expect(prsEqual([VM], [{ ...VM, state: 'OPEN' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, isDraft: true }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, title: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, url: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, updatedAt: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, headRefName: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, headOwner: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [{ ...VM, headRepo: 'x' }])).toBe(false);
+ expect(prsEqual([VM], [])).toBe(false);
+ });
+});