From 7ac122ce5de8ba6f54ebfd8ad1efb08c45823581 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 16 Oct 2026 21:49:16 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(Branches):=20=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=A1=8C=E5=8F=B3=E4=BE=A7=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=85=B3=E8=81=94=20PR=20=E7=8A=B6=E6=80=81=E4=B8=8E=20PR=20?= =?UTF-8?q?=E5=8F=B7;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches 视图远程分支(含 Favorites 内远程条目)行右侧 description 位显示关联 GitHub Pull Request 徽标(#138 Merged 形态),云图标按状态着色(Merged 紫 / Closed 红 / Open 绿 / Draft 弱化),Tooltip 增 PR 号/标题与经反 SSRF 主机白名单 校验的 Open on GitHub 链接。收藏黄色与活动蓝色优先于 PR 状态色。 - engine/pr 纯逻辑四件:types(PullRequestVM)/ graphql-query(UPDATED_AT 倒序 全态列表查询,$variables 防注入)/ model(宽容解析 + prBadge + prsEqual)/ match(fork 全名 → 同仓 → 唯一兜底三级匹配,歧义宁缺勿错,剥 remote 前缀复用 resolveRemoteBranch 最长前缀); - adapter GitHubPrService:复用 CI 服务 getGitHubContext()(remote/provider 单一 事实源),分页 ≤2×50、TTL 60s、in-flight 去重、401/403/限流冷却独立降级, 未登录/非 GitHub/关闭 → 零请求静默休眠; - 树集成:ensurePrData 异步 kick(索引与快照不同才 fire 一次,引用相等守卫无 刷新循环,repoRoot 竞态守卫防多根串染),GitHubAuth 增 onDidChange 透传、 mdTooltip 增 links 链接行(仅含链接实例放开 isTrusted); - 配置 hyperGit.branches.pr.enabled(默认 true,scope resource); - 单测 25 例(pr-graphql-query / pr-model / pr-match),全套 454/454 绿。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini Code Assist](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- package.json | 6 + src/adapter/ci/github-auth.ts | 5 + src/adapter/ci/github-ci-service.ts | 11 ++ src/adapter/pr/github-pr-service.ts | 249 ++++++++++++++++++++++++++++ src/adapter/tree/branches-tree.ts | 101 ++++++++++- src/adapter/tree/tree-tooltip.ts | 28 +++- src/engine/pr/graphql-query.ts | 44 +++++ src/engine/pr/match.ts | 92 ++++++++++ src/engine/pr/model.ts | 112 +++++++++++++ src/engine/pr/types.ts | 26 +++ src/extension.ts | 4 + tests/unit/pr-graphql-query.test.ts | 41 +++++ tests/unit/pr-match.test.ts | 100 +++++++++++ tests/unit/pr-model.test.ts | 103 ++++++++++++ 14 files changed, 914 insertions(+), 8 deletions(-) create mode 100644 src/adapter/pr/github-pr-service.ts create mode 100644 src/engine/pr/graphql-query.ts create mode 100644 src/engine/pr/match.ts create mode 100644 src/engine/pr/model.ts create mode 100644 src/engine/pr/types.ts create mode 100644 tests/unit/pr-graphql-query.test.ts create mode 100644 tests/unit/pr-match.test.ts create mode 100644 tests/unit/pr-model.test.ts diff --git a/package.json b/package.json index 6035cb8..b71b64a 100644 --- a/package.json +++ b/package.json @@ -1532,6 +1532,12 @@ "default": "auto", "markdownDescription": "CI data source. `auto` is resolved by the origin host (github.com → github.com, otherwise GitHub Enterprise).", "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.", + "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..0f12f82 100644 --- a/src/adapter/ci/github-ci-service.ts +++ b/src/adapter/ci/github-ci-service.ts @@ -251,6 +251,17 @@ export class GitHubCiService implements vscode.Disposable { return this.resolveRemote(); } + /** + * 对外暴露远程坐标 + 认证 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 缓存;优先配置名 → origin → 首个可解析)。 */ private resolveRemote(): GitHubRemote | null { const repoRoot = this.service.repoRoot ?? ''; diff --git a/src/adapter/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts new file mode 100644 index 0000000..d6fea85 --- /dev/null +++ b/src/adapter/pr/github-pr-service.ts @@ -0,0 +1,249 @@ +/** + * Branches 视图 PR 徽标服务(adapter 层,唯一触碰 vscode/网络)。 + * + * 编排:复用 CI 服务的 remote/provider 单一事实源(getGitHubContext)→ 取 token → GraphQL + * 分页拉取最近更新的 PR(UPDATED_AT 倒序,全态)→ engine `buildPrIndex` 预计算匹配索引 → + * 按 repoRoot + TTL 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, type GitHubRemote } from '../../engine/ci/remote-parser'; +import { extractRateLimit } from '../../engine/ci/model'; +import { buildPrQuery } from '../../engine/pr/graphql-query'; +import { parsePullRequestsResponse, prsEqual } from '../../engine/pr/model'; +import { buildPrIndex, 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)。 */ +const REQUEST_TIMEOUT_MS = 15_000; +/** 剩余点数低于此阈值进入限流冷却(与 CI 服务同阈值,冷却互相独立)。 */ +const RATE_FLOOR = 100; + +interface CacheEntry { + readonly repoRoot: string; + readonly prs: readonly PullRequestVM[]; + readonly index: RemotePrIndex; + readonly expires: number; +} + +export class GitHubPrService implements vscode.Disposable { + /** 登录/登出透传(订阅 GitHubAuth 会话变更),供树侧清快照重取。构造器内赋值(参数属性时序)。 */ + readonly onDidChangeAuth: vscode.Event; + + private cache: CacheEntry | null = null; + private inflight: Promise | undefined; + private cooldownUntil = 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.onDidChangeAuth = this.auth.onDidChange; + this.disposables.push( + // 配置开关变化后清缓存(树侧同订阅触发重渲染)。 + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('hyperGit.branches.pr')) { + this.cache = null; + } + }), + ); + } + + /** + * 拉取(或复用)当前仓库的 PR 匹配索引。不可用(非 GitHub / 未登录 / 限流 / 关闭 / 失败)→ + * null,绝不抛错。数据未变时返回缓存的同一索引实例(树侧引用相等即免 fire)。 + */ + async getPullRequests(): Promise { + if (!this.enabled()) { + return null; + } + if (this.inCooldown()) { + return this.cache?.index ?? null; // 冷却期只走缓存(镜像 CI 策略) + } + const ctx = this.ci.getGitHubContext(); + if (!ctx) { + return null; // 非 GitHub 远程 / provider 不匹配 → 静默休眠 + } + const repoRoot = this.service.repoRoot ?? ''; + if (this.cache && this.cache.repoRoot === repoRoot && Date.now() < this.cache.expires) { + return this.cache.index; + } + if (this.inflight) { + return this.inflight; + } + this.inflight = this.fetchAll(ctx.remote, ctx.provider, repoRoot); + try { + return await this.inflight; + } finally { + 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 host = uri.hostname.toLowerCase(); + const remote = this.ci.getGitHubContext()?.remote; + const allowed = + host === 'github.com' || + host.endsWith('.github.com') || + (!!remote && (host === remote.host || host === `api.${remote.host}`)); + return allowed ? uri.toString() : null; + } + + dispose(): void { + this.abortController.abort(); + this.disposables.forEach((d) => d.dispose()); + } + + // ─── 内部 ──────────────────────────────────────────────────────────────── + + 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 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; // 未授权:不缓存,登录后即取 + } + + 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); + if (!json) { + return null; + } + const parsed = parsePullRequestsResponse(json); + if (!parsed) { + return null; + } + prs.push(...parsed.prs); + if (!parsed.hasNextPage || !parsed.endCursor) { + break; + } + after = parsed.endCursor; + } + + // 数据未变:续期并返回同一索引实例(树侧引用相等 → 免 fire)。 + if (this.cache && this.cache.repoRoot === repoRoot && prsEqual(this.cache.prs, prs)) { + this.cache = { ...this.cache, expires: Date.now() + TTL_MS }; + return this.cache.index; + } + const remotes = (this.service.repo?.state.remotes ?? []).map((r) => r.name); + const index = buildPrIndex(prs, { repoOwner: remote.owner, remotes }); + this.cache = { repoRoot, prs, index, 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, + ): Promise { + let res: Response; + try { + res = await this.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 } }), + }); + } catch (e) { + if ((e as Error)?.name !== 'AbortError') { + this.logger.warn(`PR 请求失败:${(e as Error).message ?? e}`); + } + return null; + } + if (res.status === 401) { + this.auth.invalidate(provider); // token 失效,强制下次重解析会话 + return null; + } + 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 null; + } + let json: unknown; + try { + json = await res.json(); + } catch { + return null; + } + 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) { + 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; + } + + /** 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); + } + } +} diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index 90b8d79..bbd8399 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,35 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider private groupingKey: string; /** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */ private groupingEnabled: boolean; + /** PR 匹配索引快照(null = 无数据:未登录/非 GitHub/未就绪 → 行为与无徽标一致)。 */ + private prIndex: RemotePrIndex | null = null; + private prEnsureInFlight: Promise | undefined; 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 开关变化:清快照 + 刷新(服务端缓存由服务自身订阅同配置复位)。 + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('hyperGit.branches.pr')) { + this.prIndex = null; + this.refresh(); + } + }), + // 登录/登出:清快照重取(登出即隐去徽标;登录后无需等待 git 事件)。 + this.prService.onDidChangeAuth(() => { + this.prIndex = null; + this.refresh(); + }), + ); } /** @@ -88,6 +128,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } this.groupingKey = key; this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true; + this.prIndex = null; // 服务端缓存按 repoRoot 独立分键,切库后重取 this.refresh(); } @@ -122,6 +163,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 +312,32 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } } + /** + * 异步取 PR 索引(fire-and-forget,不阻塞建树):索引就绪且与快照不同才 fire 一次。 + * 终止性:服务端 TTL 命中与数据未变均返回同一实例 → 引用相等守卫不再 fire;多根切换竞态由 + * 「完成后 repoRoot 已变则丢弃」守卫兜住。服务自身保证不抛(此处 catch 纯防御)。 + */ + private ensurePrData(): void { + if (this.prEnsureInFlight) { + return; + } + const root = this.service.repoRoot; + this.prEnsureInFlight = this.prService + .getPullRequests() + .then((index) => { + if (index && index !== this.prIndex && this.service.repoRoot === root) { + this.prIndex = index; + this._onDidChange.fire(undefined); + } + }) + .catch(() => { + // 服务已保证静默降级;此处仅防未预期异常逃逸刷屏。 + }) + .finally(() => { + this.prEnsureInFlight = undefined; + }); + } + getTreeItem(element: BranchNode): vscode.TreeItem { if (element.kind === 'group') { const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded); @@ -298,23 +366,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 +399,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 +431,12 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } rows.push(['Upstream', `${ref.upstream}${track.length ? ` (${track.join(', ')})` : ''}`]); } - return mdTooltip(rows, { title: ref.shortName }); + // 链接行经服务端主机白名单校验(反 SSRF)后才入 Tooltip。 + const safeUrl = pr ? this.prService.safePrUrl(pr.url) : null; + return mdTooltip(rows, { + title: ref.shortName, + links: safeUrl ? [{ label: 'Open', text: `Pull Request #${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..57db9a7 100644 --- a/src/adapter/tree/tree-tooltip.ts +++ b/src/adapter/tree/tree-tooltip.ts @@ -6,8 +6,21 @@ import { formatRelative } from '../../engine/log/format-time'; * * 设计:可选标题行 + 一组 `**label:** value` 行;value 按是否含路径/分隔符自动 code-fence(提升 ref/路径可读性)。 * 取代各树 `\n` 拼接的纯文本 tooltip,统一粗体标签 + code 样式,深/浅主题自适应。 + * 可选 `links` 追加可点击链接行(如「Open on GitHub」)——仅含链接行的实例放开 isTrusted, + * href 由调用方先行主机白名单校验(反 SSRF),此处再做括号消毒防链接语法注入。 */ -export function mdTooltip(rows: ReadonlyArray, opts?: { title?: string }): vscode.MarkdownString { + +/** 可点击链接行(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(无冗余属性赋值);isTrusted 缺省 false(安全默认)。 const md = new vscode.MarkdownString('', true); if (opts?.title) { @@ -20,6 +33,14 @@ export function mdTooltip(rows: ReadonlyArray, opts?: const isPathLike = /[/.]/.test(value); // 路径或 ref → code 包裹 md.appendMarkdown(`**${escapeMd(label)}:** ${isPathLike ? '`' + escapeMd(value) + '`' : escapeMd(value)} \n`); } + const links = opts?.links ?? []; + if (links.length > 0) { + // http 链接在树 Tooltip 可点击需 isTrusted;href 已过调用方白名单,此处消毒括号防逃逸。 + md.isTrusted = true; + for (const l of links) { + md.appendMarkdown(`**${escapeMd(l.label)}:** [${escapeMd(l.text)}](${sanitizeHref(l.href)}) \n`); + } + } return md; } @@ -28,6 +49,11 @@ function escapeMd(s: string): string { return s.replace(/([\\`|])/g, '\\$1'); } +/** 链接 href 消毒:转义反斜杠/反引号,右括号百分号编码(防提前闭合 `[text](href)` 语法)。 */ +function sanitizeHref(href: string): string { + return escapeMd(href).replace(/\)/g, '%29'); +} + /** * ISO 时间 → 相对人可读描述,委托 engine/log/format-time.formatRelative(单一事实源, * 措辞对齐官方 GRAPH)。用于 stash/shelf 行内描述;技术标识保留在 Tooltip。 diff --git a/src/engine/pr/graphql-query.ts b/src/engine/pr/graphql-query.ts new file mode 100644 index 0000000..5193447 --- /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 } } + } + } +}`; + return { query }; +} diff --git a/src/engine/pr/match.ts b/src/engine/pr/match.ts new file mode 100644 index 0000000..dec7024 --- /dev/null +++ b/src/engine/pr/match.ts @@ -0,0 +1,92 @@ +/** + * 远程分支 → Pull Request 匹配(纯逻辑,零 vscode 依赖)。 + * + * 匹配对象是「剥掉 remote 前缀后的分支路径」与 PR 的 `headRefName`(head 仓库内的分支名)。 + * 前缀剥离复用 {@link resolveRemoteBranch} 的最长前缀匹配(remote 名可含 `/`)。三级优先: + * - **a(fork 全名)**:shortName 恰为 `${headOwner}/${headRefName}`——remote 本身以 fork + * owner 命名(如 `mylogin/feature`)的场景,无需剥离即精确命中; + * - **b(同仓)**:剥离后 `branch === headRefName && headOwner === repoOwner`——dependabot / + * 仓内功能分支的主场景; + * - **c(唯一兜底)**:剥离后 `branch === headRefName` 且候选唯一;歧义(多 fork 同名分支)→ + * 不匹配(宁缺勿错,绝不显示错误 PR)。 + * + * 同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。 + */ + +import { resolveRemoteBranch } from '../ref/remote-ref'; +import type { PullRequestVM } from './types'; + +/** buildPrIndex 入参(构建期快照)。 */ +export interface PrIndexInit { + /** 当前仓库 owner(Tier b 同仓判定基准)。 */ + readonly repoOwner: string; + /** 已知 remote 名列表(最长前缀剥离用,事实源 `repo.state.remotes`)。 */ + readonly remotes: readonly string[]; +} + +/** 预计算索引;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 侧零遍历。 */ +export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): RemotePrIndex { + // Tier a:`owner/branch` 全名(headOwner 缺失的 PR 不参与)。 + const byForkFullName = new Map(); + // Tier b:同仓 PR 按 headRefName。 + const bySameRepoBranch = new Map(); + // Tier c 兜底:全部 PR 按 headRefName(唯一才采用)。 + const byBranchAll = new Map(); + for (const pr of prs) { + if (pr.headOwner) { + const full = `${pr.headOwner}/${pr.headRefName}`; + byForkFullName.set(full, byForkFullName.has(full) ? newer(byForkFullName.get(full)!, pr) : pr); + } + if (pr.headOwner === init.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 全名精确命中(无需剥离;命中即返回,优先级最高)。 + const forkHit = byForkFullName.get(shortName); + if (forkHit) { + return forkHit; + } + // 剥离 remote 前缀(最长前缀;纯 remote 名 / 未知 remote → null → 不匹配)。 + const target = resolveRemoteBranch(shortName, init.remotes); + 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..e15c2fb --- /dev/null +++ b/src/engine/pr/model.ts @@ -0,0 +1,112 @@ +/** + * 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; + +/** 单节点 → {@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; + return { + number: number_, + state: state as PrState, + isDraft: node.isDraft === true, + title: (node.title as string | undefined) ?? '', + url: (node.url as string | undefined) ?? '', + updatedAt: (node.updatedAt as string | undefined) ?? '', + headRefName: (node.headRefName as string | undefined) ?? '', + // fork 元数据可能为 null(如 head 仓库被删除)→ '',不参与 fork 全名匹配。 + headOwner: typeof owner === 'string' ? owner : '', + }; +} + +/** + * 解析整页响应。缺 `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 nodes = (conn as RawConn).nodes ?? []; + 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 + ) { + return false; + } + } + return true; +} diff --git a/src/engine/pr/types.ts b/src/engine/pr/types.ts new file mode 100644 index 0000000..f8816ca --- /dev/null +++ b/src/engine/pr/types.ts @@ -0,0 +1,26 @@ +/** + * 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; +} 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/pr-graphql-query.test.ts b/tests/unit/pr-graphql-query.test.ts new file mode 100644 index 0000000..9dede37 --- /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 }'); + 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..fd5eb01 --- /dev/null +++ b/tests/unit/pr-match.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { buildPrIndex, matchRemoteBranchPr } 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', + ...overrides, + }; +} + +const ACME = { repoOwner: 'acme', remotes: ['origin'] }; + +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', remotes: ['origin', '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 全名先命中)', () => { + const fork = pr({ headOwner: 'mylogin', headRefName: 'feature', number: 7 }); + const sameRepo = pr({ headOwner: 'acme', headRefName: 'mylogin/feature', number: 8 }); + const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', remotes: ['origin', 'mylogin'] }, [fork, sameRepo]); + expect(hit?.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', remotes: ['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', remotes: ['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(); + }); +}); diff --git a/tests/unit/pr-model.test.ts b/tests/unit/pr-model.test.ts new file mode 100644 index 0000000..e4b9f67 --- /dev/null +++ b/tests/unit/pr-model.test.ts @@ -0,0 +1,103 @@ +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' }, + ...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', +}; + +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 空串', () => { + const out = parsePullRequestsResponse(page([node({ headRepositoryOwner: null })])); + expect(out?.prs[0]?.headOwner).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('nodes: null / [] → 空页(合法)', () => { + expect(parsePullRequestsResponse(page([null]))?.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], [])).toBe(false); + }); +}); From fa21590a0e9868cd5571b058410761dbbb73c41d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 16 Oct 2026 22:10:54 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs(Branches):=20PR=20=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=96=87=E6=A1=A3=20+=20archify=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=B5=81=E5=9B=BE=E5=9B=9B=E4=BB=B6=E5=A5=97=E4=B8=8E?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E7=B4=A2=E5=BC=95=E5=90=8C=E6=AD=A5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/features/branches-pr-status.md:镜像 log-ci-status.md 结构——数据流(嵌 archify 图)、刷新与终止性、三级匹配、认证与安全(safePrUrl 反 SSRF)、限流 与性能(TTL 60s / ≤2×50 / 冷却独立)、边界行为表、配置与实现索引、验证清单; - archify 四件套:mermaid 源图 + 交互 HTML(finalize --quality showcase 四道 门禁全过)+ 深浅双主题 SVG(经 svg-light/svg-dark 直出并浏览器渲染校验); - 溯源矩阵与 archify 索引各增一行(锚定实现提交 7ac122c); - knowledge-map:功能文档条目 + engine 16 模块 / adapter 增 pr/; - CHANGELOG [Unreleased] Added 条目;.gitignore 增 .archify/ 工作目录。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini Code Assist](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .gitignore | 1 + CHANGELOG.md | 4 + docs/.agents/knowledge-map.md | 5 +- docs/assets/architecture/README.md | 1 + .../branches-pr-status-dataflow.dark.svg | 655 + .../features/branches-pr-status-dataflow.html | 13806 ++++++++++++++++ .../branches-pr-status-dataflow.light.svg | 655 + docs/assets/mermaid/README.md | 1 + .../features/branches-pr-status-dataflow.mmd | 25 + docs/features/branches-pr-status.md | 77 + 10 files changed, 15228 insertions(+), 2 deletions(-) create mode 100644 docs/assets/architecture/features/branches-pr-status-dataflow.dark.svg create mode 100644 docs/assets/architecture/features/branches-pr-status-dataflow.html create mode 100644 docs/assets/architecture/features/branches-pr-status-dataflow.light.svg create mode 100644 docs/assets/mermaid/features/branches-pr-status-dataflow.mmd create mode 100644 docs/features/branches-pr-status.md 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..08c3231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 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/` 四模块 + 25 例单测。详见 [功能文档](./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 @@ + + + Branches 视图 PR 徽标数据流 + 由 Archify 生成的数据流图。 + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 树触发 + + + 02 / 门控(adapter) + + + 03 / GraphQL 取数 + + + 04 / 索引与缓存 + + + 05 / 渲染 + + + + + + + + + + + + + + + + Branches 树 · getChildren · 01 / 树触发 · fire-and-forget + + + + Branches 树 + getChildren + fire-and-forget + + + + 首轮渲染 · 无徽标先行 · 不阻塞 · 01 / 树触发 + + + + 首轮渲染 + 无徽标先行 · 不阻塞 + + + + 可用性门控 · 开关/远程/已登录 · 02 / 门控(adapter) · 静默休眠 + + + + 可用性门控 + 开关/远程/已登录 + 静默休眠 + + + + GraphQL 分页取数 · 最近更新 · ≤2×50 · 03 / GraphQL 取数 + + + + GraphQL 分页取数 + 最近更新 · ≤2×50 + + + + GitHub API · pullRequests · 03 / GraphQL 取数 · HTTPS + + + + GitHub API + pullRequests + HTTPS + + + + TTL 缓存 · repoRoot + 60s · 04 / 索引与缓存 · no-change 同实例 + + + + TTL 缓存 + repoRoot + 60s + no-change 同实例 + + + + buildPrIndex · 三级匹配 · 宁缺勿错 · 04 / 索引与缓存 · 歧义宁缺 + + + + buildPrIndex + 三级匹配 · 宁缺勿错 + 歧义宁缺 + + + + 远程分支行 · #N 徽标 · 图标着色 · 05 / 渲染 + + + + 远程分支行 + #N 徽标 · 图标着色 + + + + 悬停 Tooltip · PR 号 / 标题 / 链接 · 05 / 渲染 + + + + 悬停 Tooltip + PR 号 / 标题 / 链接 + + + + + + 异步 kick + 不阻塞建树 + + + + 先行渲染 + 无徽标 + + + + 不可用 → null + 零请求 · 零徽标 + + + + 未命中 / 过期 + 发起取数 + + + + Bearer token · $variables + GraphQL over HTTPS + + + + PullRequestVM + 宽容解析 + + + + 索引写缓存 + no-change 续期同实例 + + + + getPullRequests() 索引 + 索引 ≠ 快照才 fire 一次 + + + + TTL 命中 + 同实例零网络 + + + + 悬停 + 白名单链接 + + + + + 图例 + + + 主要数据 + + + + 策略 / PII + + + + 异步批处理 + + + + 数据存储 + + + + 数据流 + + + \ 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 徽标数据流 + + + + + + + + +
+ +
+
+
+

Branches 视图 PR 徽标数据流

+
+
+ + + + +
+ + Branches 视图 PR 徽标数据流 + 由 Archify 生成的数据流图。 + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 树触发 + + + 02 / 门控(adapter) + + + 03 / GraphQL 取数 + + + 04 / 索引与缓存 + + + 05 / 渲染 + + + + + + + + + + + + + + + + Branches 树 · getChildren · 01 / 树触发 · fire-and-forget + + + + Branches 树 + getChildren + fire-and-forget + + + + 首轮渲染 · 无徽标先行 · 不阻塞 · 01 / 树触发 + + + + 首轮渲染 + 无徽标先行 · 不阻塞 + + + + 可用性门控 · 开关/远程/已登录 · 02 / 门控(adapter) · 静默休眠 + + + + 可用性门控 + 开关/远程/已登录 + 静默休眠 + + + + GraphQL 分页取数 · 最近更新 · ≤2×50 · 03 / GraphQL 取数 + + + + GraphQL 分页取数 + 最近更新 · ≤2×50 + + + + GitHub API · pullRequests · 03 / GraphQL 取数 · HTTPS + + + + GitHub API + pullRequests + HTTPS + + + + TTL 缓存 · repoRoot + 60s · 04 / 索引与缓存 · no-change 同实例 + + + + TTL 缓存 + repoRoot + 60s + no-change 同实例 + + + + buildPrIndex · 三级匹配 · 宁缺勿错 · 04 / 索引与缓存 · 歧义宁缺 + + + + buildPrIndex + 三级匹配 · 宁缺勿错 + 歧义宁缺 + + + + 远程分支行 · #N 徽标 · 图标着色 · 05 / 渲染 + + + + 远程分支行 + #N 徽标 · 图标着色 + + + + 悬停 Tooltip · PR 号 / 标题 / 链接 · 05 / 渲染 + + + + 悬停 Tooltip + PR 号 / 标题 / 链接 + + + + + + 异步 kick + 不阻塞建树 + + + + 先行渲染 + 无徽标 + + + + 不可用 → null + 零请求 · 零徽标 + + + + 未命中 / 过期 + 发起取数 + + + + Bearer token · $variables + GraphQL over HTTPS + + + + PullRequestVM + 宽容解析 + + + + 索引写缓存 + no-change 续期同实例 + + + + getPullRequests() 索引 + 索引 ≠ 快照才 fire 一次 + + + + TTL 命中 + 同实例零网络 + + + + 悬停 + 白名单链接 + + + + + 图例 + + + 主要数据 + + + + 策略 / PII + + + + 异步批处理 + + + + 数据存储 + + + + 数据流 + + + +

+ + + + + + + + + +
+ + + +
+ + + + 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 @@ + + + Branches 视图 PR 徽标数据流 + 由 Archify 生成的数据流图。 + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 树触发 + + + 02 / 门控(adapter) + + + 03 / GraphQL 取数 + + + 04 / 索引与缓存 + + + 05 / 渲染 + + + + + + + + + + + + + + + + Branches 树 · getChildren · 01 / 树触发 · fire-and-forget + + + + Branches 树 + getChildren + fire-and-forget + + + + 首轮渲染 · 无徽标先行 · 不阻塞 · 01 / 树触发 + + + + 首轮渲染 + 无徽标先行 · 不阻塞 + + + + 可用性门控 · 开关/远程/已登录 · 02 / 门控(adapter) · 静默休眠 + + + + 可用性门控 + 开关/远程/已登录 + 静默休眠 + + + + GraphQL 分页取数 · 最近更新 · ≤2×50 · 03 / GraphQL 取数 + + + + GraphQL 分页取数 + 最近更新 · ≤2×50 + + + + GitHub API · pullRequests · 03 / GraphQL 取数 · HTTPS + + + + GitHub API + pullRequests + HTTPS + + + + TTL 缓存 · repoRoot + 60s · 04 / 索引与缓存 · no-change 同实例 + + + + TTL 缓存 + repoRoot + 60s + no-change 同实例 + + + + buildPrIndex · 三级匹配 · 宁缺勿错 · 04 / 索引与缓存 · 歧义宁缺 + + + + buildPrIndex + 三级匹配 · 宁缺勿错 + 歧义宁缺 + + + + 远程分支行 · #N 徽标 · 图标着色 · 05 / 渲染 + + + + 远程分支行 + #N 徽标 · 图标着色 + + + + 悬停 Tooltip · PR 号 / 标题 / 链接 · 05 / 渲染 + + + + 悬停 Tooltip + PR 号 / 标题 / 链接 + + + + + + 异步 kick + 不阻塞建树 + + + + 先行渲染 + 无徽标 + + + + 不可用 → null + 零请求 · 零徽标 + + + + 未命中 / 过期 + 发起取数 + + + + Bearer token · $variables + GraphQL over HTTPS + + + + PullRequestVM + 宽容解析 + + + + 索引写缓存 + no-change 续期同实例 + + + + getPullRequests() 索引 + 索引 ≠ 快照才 fire 一次 + + + + TTL 命中 + 同实例零网络 + + + + 悬停 + 白名单链接 + + + + + 图例 + + + 主要数据 + + + + 策略 / PII + + + + 异步批处理 + + + + 数据存储 + + + + 数据流 + + + \ 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..2e1fef5 --- /dev/null +++ b/docs/features/branches-pr-status.md @@ -0,0 +1,77 @@ +# 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。 + +## 数据流 + + + + Branches 视图 PR 徽标数据流 + + +> [交互版](../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 匹配(三级优先,宁缺勿错) + +剥 remote 前缀复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`): + +| Tier | 规则 | 场景 | +|---|---|---| +| a | `shortName === ${headOwner}/${headRefName}` | remote 本身以 fork owner 命名(如 `mylogin/feature`) | +| 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`)。 +- **反 SSRF**:Tooltip 链接经 `safePrUrl` 校验(https 且主机 ∈ {github.com, `*.github.com`, 仓库主机, api.仓库主机})后才入 `isTrusted` MarkdownString。 + +## 限流与性能 + +- **分页硬上限 100 条**(UPDATED_AT 倒序 × 2×50):高流量仓中掉出前 100 的旧分支 PR 静默不显示(宁缺勿错),不做配置项。 +- **TTL 60s 缓存 + in-flight 去重**:最坏 2 请求/分钟;数据未变续期并返回同一实例。 +- **限流冷却独立**:读取 `rateLimit{remaining,resetAt}` 与 `Retry-After`,剩余点数 <100 或 403 时冷却(期间只走缓存);与 CI 服务冷却互不联动。 +- **降级**:失败/超时 → null 不缓存、留待重试;401 → 失效会话;断网/限流不崩溃、建树正常。 + +## 边界行为 + +| 场景 | 表现 | +|---|---| +| 非 GitHub 远程(GitLab 等) | 功能隐藏(零徽标、零请求) | +| 未登录 GitHub | 零徽标、零请求(登录后即取,无需 git 事件) | +| 配置关闭 | 零徽标、零请求 | +| 多 fork 同名分支(匹配歧义) | 不显示徽标(宁缺勿错) | +| 掉出最近 100 条 PR 的旧分支 | 静默无徽标 | +| Favorites 中的远程分支 | 同样显示徽标(图标收藏黄优先于 PR 状态色) | +| GitHub Enterprise | 按 origin 主机自动判定,使用 `github-enterprise` provider(需已配置 `github-enterprise.uri`) | + +## 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `hyperGit.branches.pr.enabled` | `true` | 总开关。关闭后零徽标、零请求。 | + +## 实现 + +- 引擎层(纯逻辑,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`,25 例)全绿。 +2. Extension Development Host(F5)在 GitHub 仓库:远程分支行右侧出现 `#N Open/Merged/Closed/Draft` + 图标着色;悬停见 PR 号/标题/可点击 Open on GitHub;收藏远程条目同样生效(黄标优先);未登录/非 GitHub/配置关闭 → 零徽标零请求;PR 状态变化 ≤60s(下次树刷新 + TTL 到期)自然更新;多根切换无串仓残留。 From 0e3f845d812fd48144d61b97c5641140932425ed Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 01:07:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(Branches):=20PR=20=E5=BE=BD=E6=A0=87=20?= =?UTF-8?q?Review-Fix=20=E6=94=B6=E6=95=9B=EF=BC=9A=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E3=80=81=E7=BC=93=E5=AD=98/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=80=81/=E5=A4=B1=E8=B4=A5=E9=80=80=E9=81=BF?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E4=B8=8E=E5=85=B1=E4=BA=AB=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E6=8F=90=E5=8F=96;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安全:tooltip Markdown 注入防御(codeSpan 按最长反引号串压制 fence、转义 []()、 isTrusted 白名单形态、supportThemeIcons 关闭);主机白名单收敛为共享谓词 isAllowedGitHubHost(CI openExternal 与 PR safePrUrl 统一 hostname 语义) - 正确性:getPullRequests 冷却/inflight 仓根守卫防跨仓串标;索引输入(owner/remotes) 快照参与续期判定;resolveRemote 增 remotes 签名失效(首帧冻结/改址自愈); owner 大小写不敏感匹配;登出清缓存 + 代次作废在途取数(登出即隐去徽标); Retry-After 解析 NaN 防护(parseRetryAfterMs);parseNode 字段级守卫 - 行为:失败 60s 退避且走既有缓存(无闪烁);树侧 epoch 化补拉(切库/登录/配置 在途竞态统一兜住);「Open on GitHub」措辞与文档同口径 - 复用:fetchWithTimeout 提取 graphql-http.ts 双服务共用;单测 +16 例(pr 30、 parseRetryAfterMs/isAllowedGitHubHost 7、字段守卫与判别用例) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 2 +- docs/features/branches-pr-status.md | 10 +-- package.json | 2 +- src/adapter/ci/github-ci-service.ts | 60 +++++-------- src/adapter/ci/graphql-http.ts | 27 ++++++ src/adapter/pr/github-pr-service.ts | 133 +++++++++++++++++----------- src/adapter/tree/branches-tree.ts | 35 ++++++-- src/adapter/tree/tree-tooltip.ts | 41 ++++++--- src/engine/ci/model.ts | 14 +++ src/engine/ci/remote-parser.ts | 11 +++ src/engine/pr/match.ts | 21 +++-- src/engine/pr/model.ts | 18 ++-- tests/unit/ci-model.test.ts | 22 ++++- tests/unit/ci-remote-parser.test.ts | 24 ++++- tests/unit/pr-match.test.ts | 28 +++++- tests/unit/pr-model.test.ts | 15 +++- 16 files changed, 331 insertions(+), 132 deletions(-) create mode 100644 src/adapter/ci/graphql-http.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c3231..39a55fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 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/` 四模块 + 25 例单测。详见 [功能文档](./docs/features/branches-pr-status.md)。 +- **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 diff --git a/docs/features/branches-pr-status.md b/docs/features/branches-pr-status.md index 2e1fef5..8a77210 100644 --- a/docs/features/branches-pr-status.md +++ b/docs/features/branches-pr-status.md @@ -19,7 +19,7 @@ - **首轮先行渲染**:树刷新即渲染(无徽标),`ensurePrData` 异步 kick 取索引,**不阻塞建树**。 - **无刷新循环**:索引就绪且与快照不同才 fire 一次二次渲染;TTL 命中与数据未变(`prsEqual`)均返回 **同一索引实例** → 引用相等守卫不再 fire。最坏每 TTL 到期多一轮渲染。 -- **多根切换**:`setRepoRoot` 清快照;取数完成后 `repoRoot` 已变则丢弃(防串仓);服务缓存按 repoRoot 分键。 +- **多根切换**:`setRepoRoot` 清快照;取数完成后 `repoRoot` 已变则丢弃(防串仓);服务端为携带 repoRoot 校验的单槽缓存,切库即失效重取。 - **登录/登出**:`GitHubAuth.onDidChange` 透传 → 清快照重取(登出即隐去徽标)。 ## 分支 → PR 匹配(三级优先,宁缺勿错) @@ -37,15 +37,15 @@ ## 认证与安全 - 复用 **VS Code 内置 GitHub 认证**(`vscode.authentication`,范围 `repo`),凭证绝不经过 chat / 日志 / webview;与 CI 状态共享同一会话。 -- **静默优先**:`peek({createIfNone:false})` 绝不弹窗;未登录 → 零徽标、零请求。登录入口复用 Graph 视图标题栏「登录 GitHub」按钮(`hyperGit.ci.signIn`)。 +- **静默优先**:`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。 ## 限流与性能 - **分页硬上限 100 条**(UPDATED_AT 倒序 × 2×50):高流量仓中掉出前 100 的旧分支 PR 静默不显示(宁缺勿错),不做配置项。 -- **TTL 60s 缓存 + in-flight 去重**:最坏 2 请求/分钟;数据未变续期并返回同一实例。 +- **TTL 60s 缓存 + in-flight 去重**:最坏 2 请求/分钟(持续失败按 60s 退避,同口径限频);数据未变续期并返回同一实例。 - **限流冷却独立**:读取 `rateLimit{remaining,resetAt}` 与 `Retry-After`,剩余点数 <100 或 403 时冷却(期间只走缓存);与 CI 服务冷却互不联动。 -- **降级**:失败/超时 → null 不缓存、留待重试;401 → 失效会话;断网/限流不崩溃、建树正常。 +- **降级**:失败/超时 → 走既有缓存(无缓存则无徽标)、60s 退避后重试;401 → 失效会话;断网/限流不崩溃、建树正常。 ## 边界行为 @@ -73,5 +73,5 @@ ## 验证 -1. `pnpm run check-types` + `pnpm run lint` + `pnpm run test:unit`(含 `tests/unit/pr-*.test.ts`,25 例)全绿。 +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/package.json b/package.json index b71b64a..fc7c4fe 100644 --- a/package.json +++ b/package.json @@ -1536,7 +1536,7 @@ "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.", + "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-ci-service.ts b/src/adapter/ci/github-ci-service.ts index 0f12f82..9f3f4e0 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, 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; } @@ -197,13 +200,11 @@ export class GitHubCiService implements vscode.Disposable { if (uri.scheme !== 'https') { return; } - const host = uri.authority.toLowerCase(); + // 主机白名单走共享谓词(单一事实源,与 Branches PR safePrUrl 同口径);authority 剥离 userinfo/端口取纯主机名。 + const authority = uri.authority; + const host = (authority.split('@').pop() ?? authority).replace(/:\d+$/, ''); 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(host, remote?.host)) { this.logger.warn(`拒绝打开非 GitHub 链接:${rawUrl}`); return; } @@ -246,7 +247,7 @@ export class GitHubCiService implements vscode.Disposable { return authProviderId(remote); } - /** 对外暴露当前仓库的 GitHub 坐标(复用内部 repoRoot 缓存),供 Commit 详情面板生成提交页 URL。 */ + /** 对外暴露当前仓库的 GitHub 坐标(复用内部 repoRoot + remotes 签名缓存),供 Commit 详情面板生成提交页 URL。 */ getGitHubRemote(): GitHubRemote | null { return this.resolveRemote(); } @@ -262,14 +263,19 @@ export class GitHubCiService implements vscode.Disposable { return remote && provider ? { remote, provider } : null; } - /** 解析当前仓库远程为 GitHub 坐标(按 repoRoot 缓存;优先配置名 → origin → 首个可解析)。 */ + /** + * 解析当前仓库远程为 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 ?? ''; @@ -292,7 +298,7 @@ export class GitHubCiService implements vscode.Disposable { } } } - this.remoteCache = { repoRoot, remote }; + this.remoteCache = { repoRoot, remotesKey, remote }; return remote; } @@ -359,19 +365,19 @@ export class GitHubCiService implements vscode.Disposable { await this.semaphore.acquire(); try { - const res = await this.fetchWithTimeout(fetchImpl, endpoint, { + const res = await fetchWithTimeout(fetchImpl, endpoint, { method: 'POST', headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'hyper-git-vscode' }, body, - }); + }, REQUEST_TIMEOUT_MS, this.abortController.signal); 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); + const ms = parseRetryAfterMs(res.headers.get('retry-after')); + if (ms !== null) { + this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); } return map; } @@ -403,24 +409,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..89b6ec7 --- /dev/null +++ b/src/adapter/ci/graphql-http.ts @@ -0,0 +1,27 @@ +/** + * GitHub GraphQL 共享 HTTP 传输(CI / Branches PR 两个服务共用)。 + * + * 超时/销毁可中止的 fetch 机制归此处单一实现,消除两服务各自维护同一份超时语义的漂移面; + * 冷却/限流/降级策略仍归各服务(机制与策略分离)。每请求独立 AbortController,超时与 + * 调用方 dispose 信号任一触发即中止,finally 清理监听与定时器。 + */ + +/** fetch + 单请求超时 + dispose 可中止(绝不泄漏监听器与定时器)。 */ +export async function fetchWithTimeout( + fetchImpl: typeof globalThis.fetch, + url: string, + init: RequestInit, + timeoutMs: number, + disposeSignal: AbortSignal, +): Promise { + const reqAc = new AbortController(); + const onDispose = (): void => reqAc.abort(); + disposeSignal.addEventListener('abort', onDispose, { once: true }); + const timer = setTimeout(() => reqAc.abort(), timeoutMs); + try { + return await fetchImpl(url, { ...init, signal: reqAc.signal }); + } finally { + clearTimeout(timer); + disposeSignal.removeEventListener('abort', onDispose); + } +} diff --git a/src/adapter/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts index d6fea85..70cf8d4 100644 --- a/src/adapter/pr/github-pr-service.ts +++ b/src/adapter/pr/github-pr-service.ts @@ -3,8 +3,10 @@ * * 编排:复用 CI 服务的 remote/provider 单一事实源(getGitHubContext)→ 取 token → GraphQL * 分页拉取最近更新的 PR(UPDATED_AT 倒序,全态)→ engine `buildPrIndex` 预计算匹配索引 → - * 按 repoRoot + TTL 60s 缓存(数据未变续期并返回同一实例,供树侧引用相等判无变化)。 - * 未授权不缓存(登录后即取),失败/限流降级为「无徽标 / 走缓存」,绝不抛错、绝不阻塞建树。 + * 单槽缓存(条目携带 repoRoot 供读路径校验,切库即失效重取)+ TTL 60s;数据与索引输入均未变才 + * 续期并返回同一实例,供树侧引用相等判无变化。登录态变化即清缓存并作废在途取数(登出不留旧会话 + * 数据);未授权不缓存(登录后即取),失败按 60s 退避、限流冷却降级为「无徽标 / 走缓存」, + * 绝不抛错、绝不阻塞建树。 * * 安全:token 仅用于 Authorization 头;`safePrUrl` 对入 Tooltip 的链接做 https + GitHub * 主机白名单校验(反 SSRF,镜像 CI 服务 openExternal 的姿态)。 @@ -15,8 +17,9 @@ 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, type GitHubRemote } from '../../engine/ci/remote-parser'; -import { extractRateLimit } from '../../engine/ci/model'; +import { graphqlEndpoint, isAllowedGitHubHost, 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 RemotePrIndex } from '../../engine/pr/match'; @@ -28,6 +31,8 @@ const PAGE_SIZE = 50; 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 服务同阈值,冷却互相独立)。 */ @@ -37,16 +42,27 @@ interface CacheEntry { readonly repoRoot: string; readonly prs: readonly PullRequestVM[]; readonly index: RemotePrIndex; + /** 构建索引时的输入快照(remote 增删/改名影响 Tier b/c 匹配),续期须连同比较。 */ + readonly init: { readonly repoOwner: string; readonly remotes: readonly string[] }; readonly expires: number; } +/** remotes 快照相等(顺序敏感;git 返回顺序稳定,只判逐项相等)。 */ +function sameRemotes(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + export class GitHubPrService implements vscode.Disposable { /** 登录/登出透传(订阅 GitHubAuth 会话变更),供树侧清快照重取。构造器内赋值(参数属性时序)。 */ readonly onDidChangeAuth: vscode.Event; private cache: CacheEntry | null = null; - private inflight: Promise | undefined; + private inflight: { readonly root: string; readonly promise: Promise } | undefined; + /** 登录态代次:登出/换号即推进并清缓存,在途取数完成时据此作废(防写回复活旧会话数据)。 */ + private authGeneration = 0; private cooldownUntil = 0; + /** 失败退避截止:网络/解析失败后限频重试(登录/配置变化即重置,登录后立即重取)。 */ + private failureCooldownUntil = 0; private readonly abortController = new AbortController(); private readonly disposables: vscode.Disposable[] = []; @@ -58,12 +74,19 @@ export class GitHubPrService implements vscode.Disposable { ) { this.onDidChangeAuth = this.auth.onDidChange; this.disposables.push( - // 配置开关变化后清缓存(树侧同订阅触发重渲染)。 + // 配置开关变化后清缓存与退避(树侧同订阅触发重渲染)。 vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('hyperGit.branches.pr')) { this.cache = null; + this.failureCooldownUntil = 0; } }), + // 登录/登出:清缓存与退避 + 代次推进(作废在途取数写回),保证「登出即隐去徽标、登录后即取」。 + this.auth.onDidChange(() => { + this.authGeneration++; + this.cache = null; + this.failureCooldownUntil = 0; + }), ); } @@ -75,29 +98,33 @@ export class GitHubPrService implements vscode.Disposable { if (!this.enabled()) { return null; } - if (this.inCooldown()) { - return this.cache?.index ?? null; // 冷却期只走缓存(镜像 CI 策略) + const repoRoot = this.service.repoRoot ?? ''; + if (this.inCooldown() || Date.now() < this.failureCooldownUntil) { + // 限流冷却 / 失败退避期只走缓存(镜像 CI 策略);单槽缓存须校验仓根,防切库后返回他仓索引。 + return this.cache?.repoRoot === repoRoot ? this.cache.index : null; } const ctx = this.ci.getGitHubContext(); if (!ctx) { return null; // 非 GitHub 远程 / provider 不匹配 → 静默休眠 } - const repoRoot = this.service.repoRoot ?? ''; if (this.cache && this.cache.repoRoot === repoRoot && Date.now() < this.cache.expires) { return this.cache.index; } - if (this.inflight) { - return this.inflight; + if (this.inflight && this.inflight.root === repoRoot) { + return this.inflight.promise; // 同根在途去重;跨根不共用(各自按 repoRoot 写缓存) } - this.inflight = this.fetchAll(ctx.remote, ctx.provider, repoRoot); + this.inflight = { root: repoRoot, promise: this.fetchAll(ctx.remote, ctx.provider, repoRoot) }; try { - return await this.inflight; + return await this.inflight.promise; } finally { - this.inflight = undefined; + // 只清自己的在途(跨根并发时后起者已占位,不得误清)。 + if (this.inflight?.root === repoRoot) { + this.inflight = undefined; + } } } - /** Tooltip 链接防 SSRF 校验:https 且主机 ∈ {github.com, *.github.com, 仓库主机, api.仓库主机}。 */ + /** Tooltip 链接防 SSRF 校验:https 且主机过共享白名单谓词(github.com、*.github.com、仓库主机、api.仓库主机)。 */ safePrUrl(rawUrl: string): string | null { let uri: URL; try { @@ -108,13 +135,8 @@ export class GitHubPrService implements vscode.Disposable { if (uri.protocol !== 'https:') { return null; } - const host = uri.hostname.toLowerCase(); const remote = this.ci.getGitHubContext()?.remote; - const allowed = - host === 'github.com' || - host.endsWith('.github.com') || - (!!remote && (host === remote.host || host === `api.${remote.host}`)); - return allowed ? uri.toString() : null; + return isAllowedGitHubHost(uri.hostname, remote?.host) ? uri.toString() : null; } dispose(): void { @@ -134,6 +156,9 @@ export class GitHubPrService implements vscode.Disposable { /** 全量取数(分页 ≤ MAX_PAGES)→ 建索引 + 写缓存;任一页失败 → null(不缓存,留待重试)。 */ private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise { + const generation = this.authGeneration; // 取数期间的登录态代次(完成后已变则作废) + // remotes 与 repoRoot 同时采样(首个 await 之前):完成后才读会取到切库后他仓的列表,错配写入缓存。 + const remotes = (this.service.repo?.state.remotes ?? []).map((r) => r.name); const fetchImpl = globalThis.fetch; if (typeof fetchImpl !== 'function') { this.logger.warn('当前运行时无全局 fetch,PR 徽标不可用'); @@ -148,6 +173,12 @@ export class GitHubPrService implements vscode.Disposable { if (!token) { return null; // 未授权:不缓存,登录后即取 } + // 网络已尝试后的失败统一走 fail():写失败退避(限频重试),失败走同根旧缓存(与退避读路径 + // 同口径——旧索引续显而非闪烁隐现),无缓存才无徽标。 + const fail = (): RemotePrIndex | null => { + this.failureCooldownUntil = Date.now() + FAILURE_BACKOFF_MS; + return this.cache?.repoRoot === repoRoot ? this.cache.index : null; + }; const prs: PullRequestVM[] = []; let after: string | null = null; @@ -155,11 +186,11 @@ export class GitHubPrService implements vscode.Disposable { const { query } = buildPrQuery({ first: PAGE_SIZE, after }); const json = await this.gql(remote, provider, token, query, after); if (!json) { - return null; + return fail(); } const parsed = parsePullRequestsResponse(json); if (!parsed) { - return null; + return fail(); } prs.push(...parsed.prs); if (!parsed.hasNextPage || !parsed.endCursor) { @@ -168,14 +199,22 @@ export class GitHubPrService implements vscode.Disposable { after = parsed.endCursor; } - // 数据未变:续期并返回同一索引实例(树侧引用相等 → 免 fire)。 - if (this.cache && this.cache.repoRoot === repoRoot && prsEqual(this.cache.prs, prs)) { + // 数据与索引输入(owner/remotes 快照)均未变:续期并返回同一索引实例(树侧引用相等 → 免 fire)。 + if ( + this.cache && + this.cache.repoRoot === repoRoot && + prsEqual(this.cache.prs, prs) && + this.cache.init.repoOwner === remote.owner && + sameRemotes(this.cache.init.remotes, remotes) + ) { this.cache = { ...this.cache, expires: Date.now() + TTL_MS }; return this.cache.index; } - const remotes = (this.service.repo?.state.remotes ?? []).map((r) => r.name); + if (generation !== this.authGeneration) { + return null; // 取数期间登录态已变(登出/换号):结果作废,不写缓存 + } const index = buildPrIndex(prs, { repoOwner: remote.owner, remotes }); - this.cache = { repoRoot, prs, index, expires: Date.now() + TTL_MS }; + this.cache = { repoRoot, prs, index, init: { repoOwner: remote.owner, remotes }, expires: Date.now() + TTL_MS }; return index; } @@ -189,11 +228,17 @@ export class GitHubPrService implements vscode.Disposable { ): Promise { let res: Response; try { - res = await this.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 } }), - }); + res = 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}`); @@ -205,9 +250,9 @@ export class GitHubPrService implements vscode.Disposable { return null; } if (res.status === 403) { - const retryAfter = res.headers.get('retry-after'); - if (retryAfter) { - this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + Number(retryAfter) * 1000); + const ms = parseRetryAfterMs(res.headers.get('retry-after')); + if (ms !== null) { + this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); } return null; } @@ -228,22 +273,4 @@ export class GitHubPrService implements vscode.Disposable { } return json; } - - /** 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); - } - } } diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index bbd8399..2e17caa 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -89,6 +89,8 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider /** PR 匹配索引快照(null = 无数据:未登录/非 GitHub/未就绪 → 行为与无徽标一致)。 */ private prIndex: RemotePrIndex | null = null; private prEnsureInFlight: Promise | undefined; + /** PR 取数代次:切库/登录态/配置任一变化即 +1;在途取数完成时代次已变则弃结果并对当前态补拉。 */ + private prKickEpoch = 0; constructor( private readonly service: GitRepositoryService, @@ -106,12 +108,14 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('hyperGit.branches.pr')) { this.prIndex = null; + this.prKickEpoch++; this.refresh(); } }), // 登录/登出:清快照重取(登出即隐去徽标;登录后无需等待 git 事件)。 this.prService.onDidChangeAuth(() => { this.prIndex = null; + this.prKickEpoch++; this.refresh(); }), ); @@ -128,7 +132,8 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } this.groupingKey = key; this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true; - this.prIndex = null; // 服务端缓存按 repoRoot 独立分键,切库后重取 + this.prIndex = null; // 服务端为携带 repoRoot 校验的单槽缓存,切库即失效重取 + this.prKickEpoch++; this.refresh(); } @@ -313,21 +318,29 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } /** - * 异步取 PR 索引(fire-and-forget,不阻塞建树):索引就绪且与快照不同才 fire 一次。 - * 终止性:服务端 TTL 命中与数据未变均返回同一实例 → 引用相等守卫不再 fire;多根切换竞态由 - * 「完成后 repoRoot 已变则丢弃」守卫兜住。服务自身保证不抛(此处 catch 纯防御)。 + * 异步取 PR 索引(fire-and-forget,不阻塞建树):索引就绪且与快照不同才 fire 一次; + * 结果为 null 且快照非空(如登出)时清快照 fire 隐去徽标(快照已空则不再 fire,无循环)。 + * 终止性:服务端 TTL 命中与数据未变均返回同一实例 → 引用相等守卫不再 fire;切库/登录态/配置 + * 变化统一推进 prKickEpoch,在途取数完成时代次已变则弃结果并由链尾按当前态补拉(事件用户驱动,有界)。 + * 服务自身保证不抛(此处 catch 纯防御)。 */ private ensurePrData(): void { if (this.prEnsureInFlight) { return; } - const root = this.service.repoRoot; + const epoch = this.prKickEpoch; this.prEnsureInFlight = this.prService .getPullRequests() .then((index) => { - if (index && index !== this.prIndex && this.service.repoRoot === root) { + 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(() => { @@ -335,6 +348,12 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider }) .finally(() => { this.prEnsureInFlight = undefined; + }) + .then(() => { + // 在途期间代次已变(切库/登录/配置)则按当前态补拉一次(用户驱动,有界不循环)。 + if (this.prKickEpoch !== epoch) { + this.ensurePrData(); + } }); } @@ -431,11 +450,11 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } rows.push(['Upstream', `${ref.upstream}${track.length ? ` (${track.join(', ')})` : ''}`]); } - // 链接行经服务端主机白名单校验(反 SSRF)后才入 Tooltip。 + // 链接行经服务端主机白名单校验(反 SSRF)后才入 Tooltip;措辞与功能文档同口径(Open on GitHub)。 const safeUrl = pr ? this.prService.safePrUrl(pr.url) : null; return mdTooltip(rows, { title: ref.shortName, - links: safeUrl ? [{ label: 'Open', text: `Pull Request #${pr?.number}`, href: safeUrl }] : undefined, + links: safeUrl ? [{ label: 'Open on GitHub', text: `#${pr?.number}`, href: safeUrl }] : undefined, }); } diff --git a/src/adapter/tree/tree-tooltip.ts b/src/adapter/tree/tree-tooltip.ts index 57db9a7..d1ee378 100644 --- a/src/adapter/tree/tree-tooltip.ts +++ b/src/adapter/tree/tree-tooltip.ts @@ -7,7 +7,12 @@ import { formatRelative } from '../../engine/log/format-time'; * 设计:可选标题行 + 一组 `**label:** value` 行;value 按是否含路径/分隔符自动 code-fence(提升 ref/路径可读性)。 * 取代各树 `\n` 拼接的纯文本 tooltip,统一粗体标签 + code 样式,深/浅主题自适应。 * 可选 `links` 追加可点击链接行(如「Open on GitHub」)——仅含链接行的实例放开 isTrusted, - * href 由调用方先行主机白名单校验(反 SSRF),此处再做括号消毒防链接语法注入。 + * href 由调用方先行主机白名单校验(反 SSRF),此处再做括号消毒防逃逸。 + * + * 注入防线(行值可能承载远程可控文本,如 PR 标题):值统一单行化并反斜杠转义 `[]()`; + * code-fence 路径按值内最长反引号串加长 fence(code span 内反斜杠转义无效,必须按长度压制); + * isTrusted 采用 `{ enabledCommands: [] }` 白名单形态——http(s) 链接可点、command: 链接一律失效; + * supportThemeIcons 恒关(无调用方在 tooltip 文本用 `$(icon)`,关闭可杜绝远程文本注入主题图标字形)。 */ /** 可点击链接行(label 粗体前缀 + 链接文本 + href)。 */ @@ -21,8 +26,8 @@ export function mdTooltip( rows: ReadonlyArray, opts?: { title?: string; links?: readonly TooltipLink[] }, ): vscode.MarkdownString { - // 构造器第二参即 supportThemeIcons(无冗余属性赋值);isTrusted 缺省 false(安全默认)。 - const md = new vscode.MarkdownString('', true); + // supportThemeIcons 缺省 false(安全默认:无调用方用 `$(icon)`,且杜绝远程文本注入图标字形)。 + const md = new vscode.MarkdownString(''); if (opts?.title) { md.appendMarkdown(`**${escapeMd(opts.title)}**\n\n`); } @@ -31,12 +36,13 @@ export function mdTooltip( 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;href 已过调用方白名单,此处消毒括号防逃逸。 - md.isTrusted = true; + // 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`); } @@ -44,14 +50,29 @@ export function mdTooltip( return md; } -/** MarkdownString 转义:避免反引号/管道/反斜杠破坏 code-fence 或表格语义。 */ +/** 行值单行化:换行折叠为空格(tooltip 行按单行呈现,也消除软换行后的语法注入面)。 */ +function singleLine(s: string): string { + return s.replace(/[\r\n]+/g, ' '); +} + +/** MarkdownString 转义:单行化 + 反斜杠转义反引号/管道/反斜杠与 `[]()`(防链接/图片语法注入)。 */ 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)` 语法)。 */ +/** 链接 href 消毒:先对右括号百分号编码(纵深防御,防提前闭合 `[text](href)` 语法),再经 escapeMd 转义。 */ function sanitizeHref(href: string): string { - return escapeMd(href).replace(/\)/g, '%29'); + 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..9b30869 100644 --- a/src/engine/ci/remote-parser.ts +++ b/src/engine/ci/remote-parser.ts @@ -98,3 +98,14 @@ 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}`)); +} diff --git a/src/engine/pr/match.ts b/src/engine/pr/match.ts index dec7024..ca43e3d 100644 --- a/src/engine/pr/match.ts +++ b/src/engine/pr/match.ts @@ -10,6 +10,9 @@ * - **c(唯一兜底)**:剥离后 `branch === headRefName` 且候选唯一;歧义(多 fork 同名分支)→ * 不匹配(宁缺勿错,绝不显示错误 PR)。 * + * owner 比较大小写不敏感(GitHub owner 查询不敏感、login 返回规范形,而远程 URL 侧原样保留), + * 分支名保持大小写敏感。 + * * 同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。 */ @@ -18,7 +21,7 @@ import type { PullRequestVM } from './types'; /** buildPrIndex 入参(构建期快照)。 */ export interface PrIndexInit { - /** 当前仓库 owner(Tier b 同仓判定基准)。 */ + /** 当前仓库 owner(Tier b 同仓判定基准;owner 比较大小写不敏感——URL 形与规范 login 可不同)。 */ readonly repoOwner: string; /** 已知 remote 名列表(最长前缀剥离用,事实源 `repo.state.remotes`)。 */ readonly remotes: readonly string[]; @@ -36,20 +39,22 @@ function newer(a: PullRequestVM, b: PullRequestVM): PullRequestVM { return (Number.isNaN(ta) ? -Infinity : ta) >= (Number.isNaN(tb) ? -Infinity : tb) ? a : b; } -/** 构建索引:三个 Map 预计算,lookup 侧零遍历。 */ +/** 构建索引:三个 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 不参与)。 + // 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(); + const repoOwner = init.repoOwner.toLowerCase(); for (const pr of prs) { if (pr.headOwner) { - const full = `${pr.headOwner}/${pr.headRefName}`; + const full = `${pr.headOwner.toLowerCase()}/${pr.headRefName}`; byForkFullName.set(full, byForkFullName.has(full) ? newer(byForkFullName.get(full)!, pr) : pr); } - if (pr.headOwner === init.repoOwner) { + 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); @@ -61,8 +66,10 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): } return { lookup(shortName: string): PullRequestVM | undefined { - // Tier a:fork 全名精确命中(无需剥离;命中即返回,优先级最高)。 - const forkHit = byForkFullName.get(shortName); + // Tier a:fork 全名精确命中(无需剥离;首段即 owner,折叠小写后查键;命中即返回,优先级最高)。 + const slash = shortName.indexOf('/'); + const forkHit = + slash >= 0 ? byForkFullName.get(shortName.slice(0, slash).toLowerCase() + shortName.slice(slash)) : undefined; if (forkHit) { return forkHit; } diff --git a/src/engine/pr/model.ts b/src/engine/pr/model.ts index e15c2fb..81408e5 100644 --- a/src/engine/pr/model.ts +++ b/src/engine/pr/model.ts @@ -19,6 +19,11 @@ 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') { @@ -37,10 +42,10 @@ function parseNode(node: RawNode): PullRequestVM | null { number: number_, state: state as PrState, isDraft: node.isDraft === true, - title: (node.title as string | undefined) ?? '', - url: (node.url as string | undefined) ?? '', - updatedAt: (node.updatedAt as string | undefined) ?? '', - headRefName: (node.headRefName as string | undefined) ?? '', + 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 : '', }; @@ -48,14 +53,15 @@ function parseNode(node: RawNode): PullRequestVM | null { /** * 解析整页响应。缺 `data`/`repository`/`pullRequests` → null(不缓存,留待重试); - * `nodes: null` → 空页(合法:仓库无 PR)。 + * `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 nodes = (conn as RawConn).nodes ?? []; + 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 { 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..989f3b5 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, parseGitHubRemote, commitWebUrl } from '../../src/engine/ci/remote-parser'; describe('parseGitHubRemote — 合法形态', () => { it('https(带 .git)', () => { @@ -86,3 +86,25 @@ 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); + }); +}); diff --git a/tests/unit/pr-match.test.ts b/tests/unit/pr-match.test.ts index fd5eb01..66b0c11 100644 --- a/tests/unit/pr-match.test.ts +++ b/tests/unit/pr-match.test.ts @@ -32,13 +32,37 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { expect(hit?.state).toBe('MERGED'); }); - it('Tier a 优先于 Tier b(同名双语义时 fork 全名先命中)', () => { + it('Tier a 优先于 Tier b(fork 全名与同仓分支双候选时 fork 全名先命中)', () => { const fork = pr({ headOwner: 'mylogin', headRefName: 'feature', number: 7 }); - const sameRepo = pr({ headOwner: 'acme', headRefName: 'mylogin/feature', number: 8 }); + // 同仓 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', remotes: ['origin', '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', remotes: ['origin', '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', remotes: ['origin'] }, 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', remotes: ['mylogin'] }, prs)?.number).toBe(7); + expect(matchRemoteBranchPr('MyLogin/feature', { repoOwner: 'acme', remotes: ['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', remotes: ['myfork'] }, prs); diff --git a/tests/unit/pr-model.test.ts b/tests/unit/pr-model.test.ts index e4b9f67..d2151d1 100644 --- a/tests/unit/pr-model.test.ts +++ b/tests/unit/pr-model.test.ts @@ -49,8 +49,21 @@ describe('parsePullRequestsResponse', () => { expect(out?.prs[0]?.number).toBe(139); }); - it('nodes: null / [] → 空页(合法)', () => { + 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); From ac08116628eb37e7fc511ab9a1c6d9c533fa8d6f Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 07:10:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(Branches):=20PR=20=E5=BE=BD=E6=A0=87=20?= =?UTF-8?q?Tier=20a=20=E4=B8=89=E7=BB=B4=E8=BA=AB=E4=BB=BD=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E5=8F=96=E6=95=B0=E4=B8=BB=E6=9C=BA=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=90=BD=E5=9C=B0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tier a 身份校验(用户裁决实施):GraphQL 补 headRepository{name},VM 增 headRepo, remote 身份按 host/owner/repo 三维比对(owner/repo 双侧小写、host 与本仓同 forge 实例)——排除 login 与 remote 名撞名、含 / remote 名与键同形、同 owner 异仓、 异 forge 实例的错误命中(宁缺勿错) - 取数主机策略(安全):仅 github.com 与已配置 github-enterprise.uri 实例取数, 其余「GitHub 形态」远程视同非 GitHub(零请求),杜绝 GHE token 发往任意主机 - tooltip 注入面收口:escapeMd 增转义 <>(防 CommonMark 角括号 autolink 在 isTrusted 实例生成非白名单可点链接) - 文档同口径:Tier a 表行三维校验、取数主机策略、provider/取数仓耦合 (hyperGit.log.ci.provider 钉死休眠、remote 重定向)披露 - 测试 +9 例(身份校验撞名/同形/异仓/异 host/大小写、headRepo 解析与相等性) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/features/branches-pr-status.md | 5 +- src/adapter/pr/github-pr-service.ts | 54 +++++++++++++++---- src/adapter/tree/tree-tooltip.ts | 5 +- src/engine/pr/graphql-query.ts | 2 +- src/engine/pr/match.ts | 53 +++++++++++++++---- src/engine/pr/model.ts | 5 +- src/engine/pr/types.ts | 2 + tests/unit/pr-graphql-query.test.ts | 2 +- tests/unit/pr-match.test.ts | 81 +++++++++++++++++++++++++---- tests/unit/pr-model.test.ts | 8 ++- 10 files changed, 179 insertions(+), 38 deletions(-) diff --git a/docs/features/branches-pr-status.md b/docs/features/branches-pr-status.md index 8a77210..3f5fe97 100644 --- a/docs/features/branches-pr-status.md +++ b/docs/features/branches-pr-status.md @@ -28,7 +28,7 @@ | Tier | 规则 | 场景 | |---|---|---| -| a | `shortName === ${headOwner}/${headRefName}` | remote 本身以 fork owner 命名(如 `mylogin/feature`) | +| 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 同名)→ 不显示 | @@ -39,6 +39,7 @@ - 复用 **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 外发。 ## 限流与性能 @@ -65,6 +66,8 @@ |---|---|---| | `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`(三级匹配索引)。 diff --git a/src/adapter/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts index 70cf8d4..20f9a5f 100644 --- a/src/adapter/pr/github-pr-service.ts +++ b/src/adapter/pr/github-pr-service.ts @@ -17,12 +17,12 @@ 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, type GitHubRemote } from '../../engine/ci/remote-parser'; +import { graphqlEndpoint, isAllowedGitHubHost, 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 RemotePrIndex } from '../../engine/pr/match'; +import { buildPrIndex, type PrRemoteInfo, type RemotePrIndex } from '../../engine/pr/match'; import type { PullRequestVM } from '../../engine/pr/types'; /** 单页 PR 数(GitHub 单页上限 100,取 50 平衡时延与点数)。 */ @@ -42,14 +42,17 @@ interface CacheEntry { readonly repoRoot: string; readonly prs: readonly PullRequestVM[]; readonly index: RemotePrIndex; - /** 构建索引时的输入快照(remote 增删/改名影响 Tier b/c 匹配),续期须连同比较。 */ - readonly init: { readonly repoOwner: string; readonly remotes: readonly string[] }; + /** 构建索引时的输入快照(remote 增删/改名/改址影响三级匹配),续期须连同比较。 */ + readonly init: { readonly repoOwner: string; readonly repoHost: string; readonly remotes: readonly PrRemoteInfo[] }; readonly expires: number; } -/** remotes 快照相等(顺序敏感;git 返回顺序稳定,只判逐项相等)。 */ -function sameRemotes(a: readonly string[], b: readonly string[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); +/** 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 { @@ -107,6 +110,9 @@ export class GitHubPrService implements vscode.Disposable { if (!ctx) { return null; // 非 GitHub 远程 / provider 不匹配 → 静默休眠 } + if (!this.isTrustedRemoteHost(ctx.remote)) { + return null; // 「形态可解析」但非 github.com 且非已配置 GHE 实例的远程 → 视同非 GitHub(防 token 外发任意主机) + } if (this.cache && this.cache.repoRoot === repoRoot && Date.now() < this.cache.expires) { return this.cache.index; } @@ -146,6 +152,28 @@ export class GitHubPrService implements vscode.Disposable { // ─── 内部 ──────────────────────────────────────────────────────────────── + /** + * 取数主机策略(engine `parseGitHubRemote` 只辨形态不辨主机——机制/策略分离,策略在此落地): + * 仅 github.com 或与已配置 `github-enterprise.uri` 一致的实例主机可取数;其余「GitHub 形态」 + * 远程(如 origin 指向任意第三方主机)视同非 GitHub(零徽标、零请求),杜绝把 GHE token + * 发往任意主机。 + */ + private isTrustedRemoteHost(remote: GitHubRemote): boolean { + if (remote.isGitHubDotCom) { + return true; + } + const uri = vscode.workspace.getConfiguration('github-enterprise').get('uri') ?? ''; + if (!uri) { + return false; + } + try { + const withScheme = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; + return new URL(withScheme).hostname.toLowerCase() === remote.host; + } catch { + return false; + } + } + private enabled(): boolean { return vscode.workspace.getConfiguration('hyperGit').get('branches.pr.enabled', true); } @@ -158,7 +186,12 @@ export class GitHubPrService implements vscode.Disposable { private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise { const generation = this.authGeneration; // 取数期间的登录态代次(完成后已变则作废) // remotes 与 repoRoot 同时采样(首个 await 之前):完成后才读会取到切库后他仓的列表,错配写入缓存。 - const remotes = (this.service.repo?.state.remotes ?? []).map((r) => r.name); + // 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 徽标不可用'); @@ -205,6 +238,7 @@ export class GitHubPrService implements vscode.Disposable { this.cache.repoRoot === repoRoot && prsEqual(this.cache.prs, prs) && this.cache.init.repoOwner === remote.owner && + this.cache.init.repoHost === remote.host && sameRemotes(this.cache.init.remotes, remotes) ) { this.cache = { ...this.cache, expires: Date.now() + TTL_MS }; @@ -213,8 +247,8 @@ export class GitHubPrService implements vscode.Disposable { if (generation !== this.authGeneration) { return null; // 取数期间登录态已变(登出/换号):结果作废,不写缓存 } - const index = buildPrIndex(prs, { repoOwner: remote.owner, remotes }); - this.cache = { repoRoot, prs, index, init: { repoOwner: remote.owner, remotes }, expires: Date.now() + TTL_MS }; + const index = buildPrIndex(prs, { repoOwner: remote.owner, repoHost: remote.host, remotes }); + this.cache = { repoRoot, prs, index, init: { repoOwner: remote.owner, repoHost: remote.host, remotes }, expires: Date.now() + TTL_MS }; return index; } diff --git a/src/adapter/tree/tree-tooltip.ts b/src/adapter/tree/tree-tooltip.ts index d1ee378..cd80e55 100644 --- a/src/adapter/tree/tree-tooltip.ts +++ b/src/adapter/tree/tree-tooltip.ts @@ -55,9 +55,10 @@ function singleLine(s: string): string { return s.replace(/[\r\n]+/g, ' '); } -/** MarkdownString 转义:单行化 + 反斜杠转义反引号/管道/反斜杠与 `[]()`(防链接/图片语法注入)。 */ +/** MarkdownString 转义:单行化 + 反斜杠转义反引号/管道/反斜杠、`[]()`(防链接/图片语法注入) + * 与 `<>`(防 CommonMark 角括号 autolink 在 isTrusted 实例中生成非白名单可点链接)。 */ function escapeMd(s: string): string { - return singleLine(s).replace(/([\\`|[\]()])/g, '\\$1'); + return singleLine(s).replace(/([\\`|[\]()<>])/g, '\\$1'); } /** 任意值安全入 code span:fence 长于值内最长反引号串(code span 内反斜杠转义无效,须按长度压制)。 */ diff --git a/src/engine/pr/graphql-query.ts b/src/engine/pr/graphql-query.ts index 5193447..6cb1f6e 100644 --- a/src/engine/pr/graphql-query.ts +++ b/src/engine/pr/graphql-query.ts @@ -36,7 +36,7 @@ export function buildPrQuery(input: PrQueryInput): PrQueryOutput { 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 } } + nodes { number state isDraft title url updatedAt headRefName headRepositoryOwner { login } headRepository { name } } } } }`; diff --git a/src/engine/pr/match.ts b/src/engine/pr/match.ts index ca43e3d..e47b28e 100644 --- a/src/engine/pr/match.ts +++ b/src/engine/pr/match.ts @@ -3,8 +3,12 @@ * * 匹配对象是「剥掉 remote 前缀后的分支路径」与 PR 的 `headRefName`(head 仓库内的分支名)。 * 前缀剥离复用 {@link resolveRemoteBranch} 的最长前缀匹配(remote 名可含 `/`)。三级优先: - * - **a(fork 全名)**:shortName 恰为 `${headOwner}/${headRefName}`——remote 本身以 fork - * owner 命名(如 `mylogin/feature`)的场景,无需剥离即精确命中; + * - **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 同名分支)→ @@ -19,12 +23,22 @@ 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; - /** 已知 remote 名列表(最长前缀剥离用,事实源 `repo.state.remotes`)。 */ - readonly remotes: readonly 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) 查询。 */ @@ -48,6 +62,14 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): const bySameRepoBranch = new Map(); // Tier c 兜底:全部 PR 按 headRefName(唯一才采用)。 const byBranchAll = new Map(); + // Tier b/c 最长前缀剥离用纯名列表;Tier a 身份校验用 remote 名 → URL host/owner/repo(owner/repo 双侧小写)。 + const remoteNames = init.remotes.map((r) => r.name); + const repoHost = init.repoHost.toLowerCase(); + const remoteIdentity = new Map( + init.remotes.map((r) => + [r.name.toLowerCase(), !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) { @@ -66,15 +88,26 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): } return { lookup(shortName: string): PullRequestVM | undefined { - // Tier a:fork 全名精确命中(无需剥离;首段即 owner,折叠小写后查键;命中即返回,优先级最高)。 + // Tier a:fork 全名精确命中(首段即 owner,折叠小写后查键)+ 身份校验——首段必须是真实 + // 存在的可解析 remote,且其 URL 解析 host 与本仓同实例、owner/repo 与命中 PR 的 head + // owner/head repo 双侧小写一致(防撞名与异仓劫持,见模块头注;headRepo 缺失 → '' 恒不匹配,宁缺勿错)。 const slash = shortName.indexOf('/'); - const forkHit = - slash >= 0 ? byForkFullName.get(shortName.slice(0, slash).toLowerCase() + shortName.slice(slash)) : undefined; - if (forkHit) { - return forkHit; + if (slash >= 0) { + const seg = shortName.slice(0, slash).toLowerCase(); + const forkHit = byForkFullName.get(seg + shortName.slice(slash)); + const identity = remoteIdentity.get(seg); + 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, init.remotes); + const target = resolveRemoteBranch(shortName, remoteNames); if (!target) { return undefined; } diff --git a/src/engine/pr/model.ts b/src/engine/pr/model.ts index 81408e5..04351b4 100644 --- a/src/engine/pr/model.ts +++ b/src/engine/pr/model.ts @@ -38,6 +38,7 @@ function parseNode(node: RawNode): PullRequestVM | null { 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, @@ -48,6 +49,7 @@ function parseNode(node: RawNode): PullRequestVM | null { headRefName: asString(node.headRefName), // fork 元数据可能为 null(如 head 仓库被删除)→ '',不参与 fork 全名匹配。 headOwner: typeof owner === 'string' ? owner : '', + headRepo: asString(repo), }; } @@ -109,7 +111,8 @@ export function prsEqual(a: readonly PullRequestVM[], b: readonly PullRequestVM[ x.url !== y.url || x.updatedAt !== y.updatedAt || x.headRefName !== y.headRefName || - x.headOwner !== y.headOwner + x.headOwner !== y.headOwner || + x.headRepo !== y.headRepo ) { return false; } diff --git a/src/engine/pr/types.ts b/src/engine/pr/types.ts index f8816ca..1edd795 100644 --- a/src/engine/pr/types.ts +++ b/src/engine/pr/types.ts @@ -23,4 +23,6 @@ export interface PullRequestVM { readonly headRefName: string; /** head 仓库 owner login;fork 元数据缺失(null)→ ''(不参与 fork 全名匹配)。 */ readonly headOwner: string; + /** head 仓库名;fork 惯例保留 base 名(Tier a 身份校验的 repo 维度,缺失 → '')。 */ + readonly headRepo: string; } diff --git a/tests/unit/pr-graphql-query.test.ts b/tests/unit/pr-graphql-query.test.ts index 9dede37..dad3448 100644 --- a/tests/unit/pr-graphql-query.test.ts +++ b/tests/unit/pr-graphql-query.test.ts @@ -20,7 +20,7 @@ describe('buildPrQuery', () => { 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 }'); + expect(query).toContain('number state isDraft title url updatedAt headRefName headRepositoryOwner { login } headRepository { name }'); expect(query).toContain('rateLimit { cost remaining resetAt }'); }); diff --git a/tests/unit/pr-match.test.ts b/tests/unit/pr-match.test.ts index 66b0c11..b6c9860 100644 --- a/tests/unit/pr-match.test.ts +++ b/tests/unit/pr-match.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildPrIndex, matchRemoteBranchPr } from '../../src/engine/pr/match'; +import { buildPrIndex, matchRemoteBranchPr, type PrRemoteInfo } from '../../src/engine/pr/match'; import type { PullRequestVM } from '../../src/engine/pr/types'; function pr(overrides: Partial = {}): PullRequestVM { @@ -12,16 +12,22 @@ function pr(overrides: Partial = {}): PullRequestVM { updatedAt: '2026-01-01T00:00:00Z', headRefName: 'feature', headOwner: 'acme', + headRepo: 'app', ...overrides, }; } -const ACME = { repoOwner: 'acme', remotes: ['origin'] }; +/** 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', remotes: ['origin', 'mylogin'] }, prs); + const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('mylogin')] }, prs); expect(hit?.number).toBe(7); }); @@ -36,7 +42,7 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { 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', remotes: ['origin', 'mylogin'] }, [fork, sameRepo]); + const hit = matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('mylogin')] }, [fork, sameRepo]); expect(hit?.number).toBe(7); }); @@ -46,26 +52,26 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { 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', remotes: ['origin', 'fork1'] }, prs); + 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', remotes: ['origin'] }, prs); + 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', remotes: ['mylogin'] }, prs)?.number).toBe(7); - expect(matchRemoteBranchPr('MyLogin/feature', { repoOwner: 'acme', remotes: ['MyLogin'] }, prs)?.number).toBe(7); + 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', remotes: ['myfork'] }, prs); + const hit = matchRemoteBranchPr('myfork/patch-1', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('myfork')] }, prs); expect(hit?.number).toBe(9); }); @@ -98,7 +104,7 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { it('含 / 的 remote 名(myorg/repo)正确剥离后命中 Tier b', () => { const prs = [pr({ headRefName: 'feature', number: 12 })]; - const hit = matchRemoteBranchPr('myorg/repo/feature', { repoOwner: 'acme', remotes: ['myorg/repo'] }, prs); + const hit = matchRemoteBranchPr('myorg/repo/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('myorg/repo')] }, prs); expect(hit?.number).toBe(12); }); @@ -121,4 +127,59 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { 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 index d2151d1..c83206c 100644 --- a/tests/unit/pr-model.test.ts +++ b/tests/unit/pr-model.test.ts @@ -12,6 +12,7 @@ function node(overrides: Record = {}): Record updatedAt: '2026-10-01T00:00:00Z', headRefName: 'dependabot/npm_and_yarn/x', headRepositoryOwner: { login: 'o' }, + headRepository: { name: 'r' }, ...overrides, }; } @@ -29,6 +30,7 @@ const VM: PullRequestVM = { updatedAt: '2026-10-01T00:00:00Z', headRefName: 'dependabot/npm_and_yarn/x', headOwner: 'o', + headRepo: 'r', }; describe('parsePullRequestsResponse', () => { @@ -38,9 +40,10 @@ describe('parsePullRequestsResponse', () => { expect(out?.prs[0]).toEqual(VM); }); - it('headRepositoryOwner null → headOwner 空串', () => { - const out = parsePullRequestsResponse(page([node({ headRepositoryOwner: null })])); + 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 的节点被丢弃,其余保留', () => { @@ -111,6 +114,7 @@ describe('prsEqual', () => { 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); }); }); From 91ca0a16df8c96120500c1e51b25b3039a236d02 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 09:32:09 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(CI/PR):=20=E5=8F=96=E6=95=B0=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E7=AD=96=E7=95=A5=E3=80=81openExternal=20=E5=90=8C?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E8=A7=A3=E6=9E=90=E4=B8=8E=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E5=B1=82=E5=81=A5=E5=A3=AE=E6=80=A7=E6=94=B6=E5=8F=A3;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 取数主机策略(CI+PR 双侧):共享谓词 isGitHubInstanceHost——仅 github.com 与 已配置 github-enterprise.uri 实例取数,其余「GitHub 形态」远程零请求, 杜绝 token 发往任意主机(CI status/getStatuses + PR isTrustedRemoteHost) - openExternal 改 WHATWG URL 解析:与浏览器同规范取 hostname,封死 evil.com\@github.com 类解析器分歧绕过白名单的路径 - 传输层:fetchWithTimeout 保护伞覆盖 body 读取({res,done} 契约,401/403 早退同样解除);dispose 信号已中止时入口短路(迟到监听器不触发) - 解析健壮性:parseGitHubRemote 对非法百分号序列 decode 容错返回 null (任一坏 remote URL 不再静默打死 PR 徽标功能);Tier a 身份表改精确 remote 名取键(消除大小写互异名的身份坍缩) - 文档:log-ci-status 反 SSRF 白名单同口径 + 取数主机策略条目; CHANGELOG Fixed 条目 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 4 ++ docs/features/log-ci-status.md | 3 +- src/adapter/ci/github-ci-service.ts | 65 ++++++++++++++++------------- src/adapter/ci/graphql-http.ts | 22 +++++++--- src/adapter/pr/github-pr-service.ts | 60 ++++++++++++-------------- src/engine/ci/remote-parser.ts | 31 +++++++++++++- src/engine/pr/match.ts | 9 ++-- tests/unit/ci-remote-parser.test.ts | 21 +++++++++- 8 files changed, 141 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a55fb..9149aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [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)。 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/src/adapter/ci/github-ci-service.ts b/src/adapter/ci/github-ci-service.ts index 9f3f4e0..bff5534 100644 --- a/src/adapter/ci/github-ci-service.ts +++ b/src/adapter/ci/github-ci-service.ts @@ -15,7 +15,7 @@ import type { Logger } from '../../infra/logger'; import { GitHubAuth, type GitHubAuthProviderId } from './github-auth'; import { buildCiQuery } from '../../engine/ci/graphql-query'; import { extractRateLimit, parseCiResponse, parseRetryAfterMs } from '../../engine/ci/model'; -import { authProviderId, graphqlEndpoint, isAllowedGitHubHost, parseGitHubRemote, type GitHubRemote } from '../../engine/ci/remote-parser'; +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'; @@ -105,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' }; @@ -128,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)]; @@ -191,24 +191,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; } - // 主机白名单走共享谓词(单一事实源,与 Branches PR safePrUrl 同口径);authority 剥离 userinfo/端口取纯主机名。 - const authority = uri.authority; - const host = (authority.split('@').pop() ?? authority).replace(/:\d+$/, ''); + // 主机白名单走共享谓词(单一事实源,与 Branches PR safePrUrl 同口径);hostname 已归一(无端口/userinfo)。 const remote = this.resolveRemote(); - if (!isAllowedGitHubHost(host, remote?.host)) { + 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 { @@ -223,6 +223,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', @@ -365,27 +370,31 @@ export class GitHubCiService implements vscode.Disposable { await this.semaphore.acquire(); try { - const res = await 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, }, REQUEST_TIMEOUT_MS, this.abortController.signal); - 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; - } 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) { diff --git a/src/adapter/ci/graphql-http.ts b/src/adapter/ci/graphql-http.ts index 89b6ec7..6822f02 100644 --- a/src/adapter/ci/graphql-http.ts +++ b/src/adapter/ci/graphql-http.ts @@ -3,25 +3,37 @@ * * 超时/销毁可中止的 fetch 机制归此处单一实现,消除两服务各自维护同一份超时语义的漂移面; * 冷却/限流/降级策略仍归各服务(机制与策略分离)。每请求独立 AbortController,超时与 - * 调用方 dispose 信号任一触发即中止,finally 清理监听与定时器。 + * 调用方 dispose 信号任一触发即中止;**保护伞覆盖到 body 读取完成**(done() 前)—— + * 仅到响应头即清定时器会使慢滴 body 的 res.json() 无界挂起,钉死唯一 inflight 槽。 */ -/** fetch + 单请求超时 + dispose 可中止(绝不泄漏监听器与定时器)。 */ +/** fetch + 单请求超时 + dispose 可中止;返回响应与 done()(body 消费完成后调用,解除定时器与监听)。 */ export async function fetchWithTimeout( fetchImpl: typeof globalThis.fetch, url: string, init: RequestInit, timeoutMs: number, disposeSignal: AbortSignal, -): Promise { +): 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 { - return await fetchImpl(url, { ...init, signal: reqAc.signal }); - } finally { + 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 index 20f9a5f..82c75fd 100644 --- a/src/adapter/pr/github-pr-service.ts +++ b/src/adapter/pr/github-pr-service.ts @@ -17,7 +17,7 @@ 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, parseGitHubRemote, type GitHubRemote } from '../../engine/ci/remote-parser'; +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'; @@ -153,25 +153,12 @@ export class GitHubPrService implements vscode.Disposable { // ─── 内部 ──────────────────────────────────────────────────────────────── /** - * 取数主机策略(engine `parseGitHubRemote` 只辨形态不辨主机——机制/策略分离,策略在此落地): - * 仅 github.com 或与已配置 `github-enterprise.uri` 一致的实例主机可取数;其余「GitHub 形态」 - * 远程(如 origin 指向任意第三方主机)视同非 GitHub(零徽标、零请求),杜绝把 GHE token - * 发往任意主机。 + * 取数主机策略(SSOT:{@link isGitHubInstanceHost},CI 服务同源共用):仅 github.com 或与 + * 已配置 `github-enterprise.uri` 一致的实例主机可取数;其余「GitHub 形态」远程视同非 GitHub + * (零徽标、零请求),杜绝把 GHE token 发往任意主机。 */ private isTrustedRemoteHost(remote: GitHubRemote): boolean { - if (remote.isGitHubDotCom) { - return true; - } - const uri = vscode.workspace.getConfiguration('github-enterprise').get('uri') ?? ''; - if (!uri) { - return false; - } - try { - const withScheme = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; - return new URL(withScheme).hostname.toLowerCase() === remote.host; - } catch { - return false; - } + return isGitHubInstanceHost(remote.host, vscode.workspace.getConfiguration('github-enterprise').get('uri')); } private enabled(): boolean { @@ -261,8 +248,9 @@ export class GitHubPrService implements vscode.Disposable { after: string | null, ): Promise { let res: Response; + let done: () => void; try { - res = await fetchWithTimeout( + ({ res, done } = await fetchWithTimeout( globalThis.fetch, graphqlEndpoint(remote), { @@ -272,29 +260,33 @@ export class GitHubPrService implements vscode.Disposable { }, 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; } - 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) { - this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); - } - return null; - } let json: unknown; try { - json = await res.json(); - } catch { - return null; + 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) { + 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) { diff --git a/src/engine/ci/remote-parser.ts b/src/engine/ci/remote-parser.ts index 9b30869..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); } @@ -109,3 +117,24 @@ export function isAllowedGitHubHost(host: string, remoteHost?: string): boolean 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/match.ts b/src/engine/pr/match.ts index e47b28e..b343bae 100644 --- a/src/engine/pr/match.ts +++ b/src/engine/pr/match.ts @@ -63,11 +63,12 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): // 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.toLowerCase(), !r.host || !r.owner || !r.repo ? null : { host: r.host.toLowerCase(), owner: r.owner.toLowerCase(), repo: r.repo.toLowerCase() }] as const, + [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(); @@ -93,9 +94,9 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): // owner/head repo 双侧小写一致(防撞名与异仓劫持,见模块头注;headRepo 缺失 → '' 恒不匹配,宁缺勿错)。 const slash = shortName.indexOf('/'); if (slash >= 0) { - const seg = shortName.slice(0, slash).toLowerCase(); - const forkHit = byForkFullName.get(seg + shortName.slice(slash)); - const identity = remoteIdentity.get(seg); + 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 && diff --git a/tests/unit/ci-remote-parser.test.ts b/tests/unit/ci-remote-parser.test.ts index 989f3b5..be15e2d 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, isAllowedGitHubHost, 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)', () => { @@ -108,3 +108,22 @@ describe('isAllowedGitHubHost', () => { 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); + }); +}); From 97ba3ff914ebfa00fedc7ed8f88bfc407ae1beee Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 12:21:02 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(CI/PR):=20=E5=8F=96=E6=95=B0=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=97=8F=E6=94=B6=E6=95=9B=E2=80=94=E2=80=94cacheMatc?= =?UTF-8?q?hes=20=E4=B8=89=E8=AF=BB=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E6=AC=A1=E5=8C=96=E5=A4=B1=E6=95=88;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cacheMatches 单一判定(仓根 + host/owner/repo 四维)统一 TTL 命中、冷却续供、 失败续显三条读路径,消除守卫不对称(改址/换仓/换 provider 后不续供旧仓索引) - 失效收敛为 invalidate():登录态与取数源配置(PR 开关/log.ci.*/github-enterprise.uri) 推进 fetchGeneration(作废在途写回与冷却武装)+ 清缓存 + 复位退避与限流冷却, 经 onDidInvalidate 单一事件通知树侧清快照重取(依赖清单仅服务一处维护) - in-flight 去重键扩为仓根+取数上下文(set-url 竞态不串仓);Tier a 身份表改 精确 remote 名取键(消除大小写互异名坍缩);signIn 与 status/getStatuses 同门 - package.json log.ci.provider auto 描述同步主机策略;decode 容错回归用例; 文档/注释全量同口径 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/features/branches-pr-status.md | 2 +- package.json | 2 +- src/adapter/ci/github-ci-service.ts | 3 +- src/adapter/pr/github-pr-service.ts | 111 ++++++++++++++++++---------- src/adapter/tree/branches-tree.ts | 13 +--- tests/unit/ci-remote-parser.test.ts | 5 ++ 6 files changed, 83 insertions(+), 53 deletions(-) diff --git a/docs/features/branches-pr-status.md b/docs/features/branches-pr-status.md index 3f5fe97..4d6e19f 100644 --- a/docs/features/branches-pr-status.md +++ b/docs/features/branches-pr-status.md @@ -20,7 +20,7 @@ - **无刷新循环**:索引就绪且与快照不同才 fire 一次二次渲染;TTL 命中与数据未变(`prsEqual`)均返回 **同一索引实例** → 引用相等守卫不再 fire。最坏每 TTL 到期多一轮渲染。 - **多根切换**:`setRepoRoot` 清快照;取数完成后 `repoRoot` 已变则丢弃(防串仓);服务端为携带 repoRoot 校验的单槽缓存,切库即失效重取。 -- **登录/登出**:`GitHubAuth.onDidChange` 透传 → 清快照重取(登出即隐去徽标)。 +- **登录/登出与取数源配置**:`GitHubAuth.onDidChange` 或 PR 开关 / `hyperGit.log.ci.*` / `github-enterprise.uri` 变化 → 服务推进代次(作废在途写回)+ 清缓存,经 `onDidInvalidate` 通知树清快照重取(登出即隐去徽标;切换取数仓后无需等待 git 事件)。 ## 分支 → PR 匹配(三级优先,宁缺勿错) diff --git a/package.json b/package.json index fc7c4fe..2e8bc8b 100644 --- a/package.json +++ b/package.json @@ -1530,7 +1530,7 @@ "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": { diff --git a/src/adapter/ci/github-ci-service.ts b/src/adapter/ci/github-ci-service.ts index bff5534..eb29ee6 100644 --- a/src/adapter/ci/github-ci-service.ts +++ b/src/adapter/ci/github-ci-service.ts @@ -178,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 { diff --git a/src/adapter/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts index 82c75fd..cf3d7ae 100644 --- a/src/adapter/pr/github-pr-service.ts +++ b/src/adapter/pr/github-pr-service.ts @@ -4,8 +4,8 @@ * 编排:复用 CI 服务的 remote/provider 单一事实源(getGitHubContext)→ 取 token → GraphQL * 分页拉取最近更新的 PR(UPDATED_AT 倒序,全态)→ engine `buildPrIndex` 预计算匹配索引 → * 单槽缓存(条目携带 repoRoot 供读路径校验,切库即失效重取)+ TTL 60s;数据与索引输入均未变才 - * 续期并返回同一实例,供树侧引用相等判无变化。登录态变化即清缓存并作废在途取数(登出不留旧会话 - * 数据);未授权不缓存(登录后即取),失败按 60s 退避、限流冷却降级为「无徽标 / 走缓存」, + * 续期并返回同一实例,供树侧引用相等判无变化。登录态或取数源配置变化即清缓存并作废在途取数 + * (登出不留旧会话数据、切换取数仓后立即可重取);未授权不缓存(登录后即取),失败按 60s 退避、限流冷却降级为「无徽标 / 走缓存」, * 绝不抛错、绝不阻塞建树。 * * 安全:token 仅用于 Authorization 头;`safePrUrl` 对入 Tooltip 的链接做 https + GitHub @@ -43,7 +43,7 @@ interface CacheEntry { readonly prs: readonly PullRequestVM[]; readonly index: RemotePrIndex; /** 构建索引时的输入快照(remote 增删/改名/改址影响三级匹配),续期须连同比较。 */ - readonly init: { readonly repoOwner: string; readonly repoHost: string; readonly remotes: readonly PrRemoteInfo[] }; + readonly init: { readonly repoOwner: string; readonly repoHost: string; readonly repo: string; readonly remotes: readonly PrRemoteInfo[] }; readonly expires: number; } @@ -56,13 +56,15 @@ function sameRemotes(a: readonly PrRemoteInfo[], b: readonly PrRemoteInfo[]): bo } export class GitHubPrService implements vscode.Disposable { - /** 登录/登出透传(订阅 GitHubAuth 会话变更),供树侧清快照重取。构造器内赋值(参数属性时序)。 */ - readonly onDidChangeAuth: vscode.Event; + /** 取数失效通知(登录态或取数源配置变化,服务已清缓存并推进代次),供树侧清快照重取—— + * 失效依赖清单只在服务一处维护(单一事实源,树侧不另列配置键)。 */ + private readonly _onDidInvalidate = new vscode.EventEmitter(); + readonly onDidInvalidate = this._onDidInvalidate.event; private cache: CacheEntry | null = null; - private inflight: { readonly root: string; readonly promise: Promise } | undefined; - /** 登录态代次:登出/换号即推进并清缓存,在途取数完成时据此作废(防写回复活旧会话数据)。 */ - private authGeneration = 0; + private inflight: { readonly key: string; readonly promise: Promise } | undefined; + /** 取数代次:登录态或取数源配置变化即推进并清缓存,在途取数完成时据此作废(防写回复活旧会话/旧取数仓数据)。 */ + private fetchGeneration = 0; private cooldownUntil = 0; /** 失败退避截止:网络/解析失败后限频重试(登录/配置变化即重置,登录后立即重取)。 */ private failureCooldownUntil = 0; @@ -75,21 +77,16 @@ export class GitHubPrService implements vscode.Disposable { private readonly auth: GitHubAuth, private readonly logger: Logger, ) { - this.onDidChangeAuth = this.auth.onDidChange; this.disposables.push( - // 配置开关变化后清缓存与退避(树侧同订阅触发重渲染)。 + // 配置变化 → invalidate()(推进代次 + 清缓存/退避 + 经 onDidInvalidate 通知树侧重取): + // 自身开关 + 取数源依赖(log.ci.provider/remote 决定取数仓、github-enterprise.uri 决定可信实例)。 vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration('hyperGit.branches.pr')) { - this.cache = null; - this.failureCooldownUntil = 0; + if (e.affectsConfiguration('hyperGit.branches.pr') || e.affectsConfiguration('hyperGit.log.ci') || e.affectsConfiguration('github-enterprise')) { + this.invalidate(); } }), // 登录/登出:清缓存与退避 + 代次推进(作废在途取数写回),保证「登出即隐去徽标、登录后即取」。 - this.auth.onDidChange(() => { - this.authGeneration++; - this.cache = null; - this.failureCooldownUntil = 0; - }), + this.auth.onDidChange(() => this.invalidate()), ); } @@ -102,10 +99,6 @@ export class GitHubPrService implements vscode.Disposable { return null; } const repoRoot = this.service.repoRoot ?? ''; - if (this.inCooldown() || Date.now() < this.failureCooldownUntil) { - // 限流冷却 / 失败退避期只走缓存(镜像 CI 策略);单槽缓存须校验仓根,防切库后返回他仓索引。 - return this.cache?.repoRoot === repoRoot ? this.cache.index : null; - } const ctx = this.ci.getGitHubContext(); if (!ctx) { return null; // 非 GitHub 远程 / provider 不匹配 → 静默休眠 @@ -113,18 +106,25 @@ export class GitHubPrService implements vscode.Disposable { if (!this.isTrustedRemoteHost(ctx.remote)) { return null; // 「形态可解析」但非 github.com 且非已配置 GHE 实例的远程 → 视同非 GitHub(防 token 外发任意主机) } - if (this.cache && this.cache.repoRoot === repoRoot && Date.now() < this.cache.expires) { - return this.cache.index; + const cacheHit = this.cacheMatches(repoRoot, ctx.remote) ? this.cache!.index : null; + if (this.inCooldown() || Date.now() < this.failureCooldownUntil) { + return cacheHit; // 限流冷却 / 失败退避期只走(上下文一致的)缓存,镜像 CI 策略 } - if (this.inflight && this.inflight.root === repoRoot) { - return this.inflight.promise; // 同根在途去重;跨根不共用(各自按 repoRoot 写缓存) + if (cacheHit && Date.now() < this.cache!.expires) { + return cacheHit; } - this.inflight = { root: repoRoot, promise: this.fetchAll(ctx.remote, ctx.provider, repoRoot) }; + // 在途去重键 = 仓根 + 取数上下文(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?.root === repoRoot) { + // 只清自己的在途(跨根/跨上下文并发时后起者已占位,不得误清)。 + if (this.inflight?.key === inflightKey) { this.inflight = undefined; } } @@ -147,11 +147,36 @@ export class GitHubPrService implements vscode.Disposable { 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 @@ -171,7 +196,7 @@ export class GitHubPrService implements vscode.Disposable { /** 全量取数(分页 ≤ MAX_PAGES)→ 建索引 + 写缓存;任一页失败 → null(不缓存,留待重试)。 */ private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise { - const generation = this.authGeneration; // 取数期间的登录态代次(完成后已变则作废) + const generation = this.fetchGeneration; // 取数期间的代次(登录态/取数源配置变化后作废) // remotes 与 repoRoot 同时采样(首个 await 之前):完成后才读会取到切库后他仓的列表,错配写入缓存。 // name + URL 解析 host/owner/repo:供 Tier a 身份校验(不可解析 → null;可解析但 host 与本仓 // 不同实例的 remote 也被 Tier a 排除——fork 与 base 同 forge 实例)。 @@ -193,18 +218,21 @@ export class GitHubPrService implements vscode.Disposable { if (!token) { return null; // 未授权:不缓存,登录后即取 } - // 网络已尝试后的失败统一走 fail():写失败退避(限频重试),失败走同根旧缓存(与退避读路径 - // 同口径——旧索引续显而非闪烁隐现),无缓存才无徽标。 + // 网络已尝试后的失败统一走 fail():失败走**上下文一致的**旧缓存(cacheMatches 单一判定, + // 旧索引续显而非闪烁隐现),无缓存才无徽标;失败退避只在代次未变时武装(invalidate 刚复位 + // 的退避不被在途旧代次的失败重新盖住——登录后/取数源切换后立即可重取)。 const fail = (): RemotePrIndex | null => { - this.failureCooldownUntil = Date.now() + FAILURE_BACKOFF_MS; - return this.cache?.repoRoot === repoRoot ? this.cache.index : 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); + const json = await this.gql(remote, provider, token, query, after, generation); if (!json) { return fail(); } @@ -226,16 +254,17 @@ export class GitHubPrService implements vscode.Disposable { 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.authGeneration) { - return null; // 取数期间登录态已变(登出/换号):结果作废,不写缓存 + 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, remotes }, expires: Date.now() + TTL_MS }; + this.cache = { repoRoot, prs, index, init: { repoOwner: remote.owner, repoHost: remote.host, repo: remote.repo, remotes }, expires: Date.now() + TTL_MS }; return index; } @@ -246,6 +275,7 @@ export class GitHubPrService implements vscode.Disposable { token: string, query: string, after: string | null, + generation: number, ): Promise { let res: Response; let done: () => void; @@ -275,7 +305,8 @@ export class GitHubPrService implements vscode.Disposable { } if (res.status === 403) { const ms = parseRetryAfterMs(res.headers.get('retry-after')); - if (ms !== null) { + if (ms !== null && generation === this.fetchGeneration) { + // 代次已变(登录态/取数源切换)则不武装冷却,保住 invalidate 的复位(立即可重取)。 this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); } return null; @@ -293,7 +324,7 @@ export class GitHubPrService implements vscode.Disposable { this.logger.warn(`PR 查询返回错误:${JSON.stringify(errors[0])}`); } const rl = extractRateLimit(json); - if (rl && rl.remaining !== null && rl.remaining < RATE_FLOOR) { + 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); } diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index 2e17caa..3c617ba 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -104,16 +104,9 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider this.disposables.push(service.onDidChange(() => this.refresh())); this.disposables.push(favorites.onDidChange(() => this.refresh())); this.disposables.push( - // PR 开关变化:清快照 + 刷新(服务端缓存由服务自身订阅同配置复位)。 - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration('hyperGit.branches.pr')) { - this.prIndex = null; - this.prKickEpoch++; - this.refresh(); - } - }), - // 登录/登出:清快照重取(登出即隐去徽标;登录后无需等待 git 事件)。 - this.prService.onDidChangeAuth(() => { + // PR 取数失效(登录/登出、PR 开关、取数源配置——依赖清单由服务单一维护):清快照重取 + // (登出即隐去徽标;登录后与取数仓切换后无需等待 git 事件)。 + this.prService.onDidInvalidate(() => { this.prIndex = null; this.prKickEpoch++; this.refresh(); diff --git a/tests/unit/ci-remote-parser.test.ts b/tests/unit/ci-remote-parser.test.ts index be15e2d..b4d2ecd 100644 --- a/tests/unit/ci-remote-parser.test.ts +++ b/tests/unit/ci-remote-parser.test.ts @@ -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', () => {