diff --git a/CHANGELOG.md b/CHANGELOG.md index 895940a..805ae44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Added + +- **Branches 视图本地分支行显示 PR 状态徽标**:Local Branches(含 Favorites 本地条目与活动分支)行右侧以与远程行一致的形态显示关联 GitHub PR(`#N Merged/Open/Closed/Draft`),非活动分支图标按状态着色(Merged 紫 / Closed 红 / Open 绿 / Draft 弱化;活动蓝、收藏黄优先,活动分支呈 `active #N …`),悬停 Tooltip 与远程行同构(PR 号 / 标题 / Open on GitHub)。匹配语义:本地**裸分支名**走同仓 Tier b → 唯一兜底 Tier c(与远程行同口径,宁缺勿错),**不剥 remote 前缀、不做 Tier a fork 身份匹配**(本地名不携带 remote 身份,本地名 `origin/feature` 只匹配 headRefName 字面同名 PR);不用 `ref.upstream` 参与匹配(upstream 仅 CLI 兜底路径填充且 v0.0.22 起树节点 upstream 数据不受信,裸名匹配消除双数据路径下的徽标闪烁)。取数链路零改动(复用同一 GraphQL 索引,`RemotePrIndex` 更名 `BranchPrIndex` 并新增 `lookupLocal` 入口),engine 增量全量单测覆盖。详见 [功能文档](./docs/features/branches-pr-status.md)。 + ## [0.0.22] - 2026-10-24 — Graph 过滤持久化与激活指示 · Branches 就地更新 · 开发依赖升级 ### Added diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index e33a24e..b85e6d4 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -17,7 +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 缓存与无循环刷新、静默边界与配置。 +- [Branches 视图分支 PR 状态徽标](../docs/features/branches-pr-status.md) — 本地与远程分支行右侧显示关联 GitHub PR(`#138 Merged` + 图标按态着色):远程短名三级匹配 + 本地裸名 Tier b/c、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`、按仓库持久化。 diff --git a/docs/features/branches-pr-status.md b/docs/features/branches-pr-status.md index 4d6e19f..c093b93 100644 --- a/docs/features/branches-pr-status.md +++ b/docs/features/branches-pr-status.md @@ -1,9 +1,10 @@ -# Branches 视图远程分支 PR 状态徽标 +# 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 视图(`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。 ## 数据流 @@ -24,7 +25,7 @@ ## 分支 → PR 匹配(三级优先,宁缺勿错) -剥 remote 前缀复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`): +**远程短名**(`origin/feature` 形态)剥 remote 前缀复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`): | Tier | 规则 | 场景 | |---|---|---| @@ -32,6 +33,12 @@ | b | 剥离后 `branch === headRefName && headOwner === repoOwner` | dependabot / 仓内功能分支(主场景) | | c | 剥离后 `branch === headRefName` 且候选**唯一** | 专属 fork remote;歧义(多 fork 同名)→ 不显示 | +**本地裸名**(Local Branches 行,含 Favorites 本地条目):**不剥前缀、无 Tier a**——本地分支名不携带 +remote 身份(无 URL 可作 fork 身份校验,本地名形如 `owner/branch` 不得被当作 fork 全名),按完整裸名 +走 Tier b 同仓 → Tier c 唯一兜底;本地名 `origin/feature` 只匹配 headRefName 字面同名的 PR。不用 +`ref.upstream` 参与匹配:upstream 仅 CLI 兜底路径填充(API 路径刻意不填,v0.0.22 起树节点 upstream +数据不受信),参与匹配会造成首帧有徽标、刷新后消失的闪烁;裸名匹配与 refs 数据来源路径解耦。 + 同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。 ## 认证与安全 @@ -57,7 +64,10 @@ | 配置关闭 | 零徽标、零请求 | | 多 fork 同名分支(匹配歧义) | 不显示徽标(宁缺勿错) | | 掉出最近 100 条 PR 的旧分支 | 静默无徽标 | -| Favorites 中的远程分支 | 同样显示徽标(图标收藏黄优先于 PR 状态色) | +| 本地分支(含活动分支) | 同样显示徽标(活动蓝 / 收藏黄图标优先于 PR 状态色,状态由徽标文本承载,形如 `active #20 Open`) | +| 本地名形如 `owner/branch`(fork checkout 形态) | 本地行无徽标(无 Tier a,不做 fork 身份匹配);对应远程行可经 Tier a 有徽标,属预期分歧 | +| 本地裸名与唯一 fork PR 撞名 | 显示该 PR(Tier c 唯一兜底)——属同名信息展示,非本分支已合并的凭证 | +| Favorites 中的分支(本地 / 远程) | 同样显示徽标(图标收藏黄优先于 PR 状态色) | | GitHub Enterprise | 按 origin 主机自动判定,使用 `github-enterprise` provider(需已配置 `github-enterprise.uri`) | ## 配置 @@ -70,7 +80,7 @@ provider 与取数仓判定复用 `hyperGit.log.ci.provider` / `hyperGit.log.ci. ## 实现 -- 引擎层(纯逻辑,Vitest 可测):[`engine/pr/`](../../src/engine/pr) — `types.ts`(PullRequestVM)、`graphql-query.ts`($variables 防注入列表查询)、`model.ts`(宽容解析 + prBadge + prsEqual)、`match.ts`(三级匹配索引)。 +- 引擎层(纯逻辑,Vitest 可测):[`engine/pr/`](../../src/engine/pr) — `types.ts`(PullRequestVM)、`graphql-query.ts`($variables 防注入列表查询)、`model.ts`(宽容解析 + prBadge + prsEqual)、`match.ts`(`BranchPrIndex`:远程短名三级匹配 + 本地裸名 `lookupLocal` Tier b/c)。 - 适配层(唯一触碰 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)。 @@ -78,3 +88,4 @@ provider 与取数仓判定复用 `hyperGit.log.ci.provider` / `hyperGit.log.ci. 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 到期)自然更新;多根切换无串仓残留。 +3. 本地分支行:已通过 PR 合并的本地分支右侧出现 `#N Merged`(非活动分支图标按状态着色);活动分支显示 `active #N …` 且图标保持蓝;收藏本地分支黄标优先、徽标文本仍在;同一逻辑分支的本地行与远程行显示同一 PR。 diff --git a/package.json b/package.json index 5becf9e..91b5296 100644 --- a/package.json +++ b/package.json @@ -1566,7 +1566,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 (shown when CI status is enabled, on by default).", + "markdownDescription": "Show the associated GitHub pull request (e.g. `#138 Merged`) on the right of each local and 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/pr/github-pr-service.ts b/src/adapter/pr/github-pr-service.ts index cf3d7ae..d880e85 100644 --- a/src/adapter/pr/github-pr-service.ts +++ b/src/adapter/pr/github-pr-service.ts @@ -22,7 +22,7 @@ import { extractRateLimit, parseRetryAfterMs } from '../../engine/ci/model'; import { fetchWithTimeout } from '../ci/graphql-http'; import { buildPrQuery } from '../../engine/pr/graphql-query'; import { parsePullRequestsResponse, prsEqual } from '../../engine/pr/model'; -import { buildPrIndex, type PrRemoteInfo, type RemotePrIndex } from '../../engine/pr/match'; +import { buildPrIndex, type BranchPrIndex, type PrRemoteInfo } from '../../engine/pr/match'; import type { PullRequestVM } from '../../engine/pr/types'; /** 单页 PR 数(GitHub 单页上限 100,取 50 平衡时延与点数)。 */ @@ -41,7 +41,7 @@ const RATE_FLOOR = 100; interface CacheEntry { readonly repoRoot: string; readonly prs: readonly PullRequestVM[]; - readonly index: RemotePrIndex; + readonly index: BranchPrIndex; /** 构建索引时的输入快照(remote 增删/改名/改址影响三级匹配),续期须连同比较。 */ readonly init: { readonly repoOwner: string; readonly repoHost: string; readonly repo: string; readonly remotes: readonly PrRemoteInfo[] }; readonly expires: number; @@ -62,7 +62,7 @@ export class GitHubPrService implements vscode.Disposable { readonly onDidInvalidate = this._onDidInvalidate.event; private cache: CacheEntry | null = null; - private inflight: { readonly key: string; readonly promise: Promise } | undefined; + private inflight: { readonly key: string; readonly promise: Promise } | undefined; /** 取数代次:登录态或取数源配置变化即推进并清缓存,在途取数完成时据此作废(防写回复活旧会话/旧取数仓数据)。 */ private fetchGeneration = 0; private cooldownUntil = 0; @@ -94,7 +94,7 @@ export class GitHubPrService implements vscode.Disposable { * 拉取(或复用)当前仓库的 PR 匹配索引。不可用(非 GitHub / 未登录 / 限流 / 关闭 / 失败)→ * null,绝不抛错。数据未变时返回缓存的同一索引实例(树侧引用相等即免 fire)。 */ - async getPullRequests(): Promise { + async getPullRequests(): Promise { if (!this.enabled()) { return null; } @@ -195,7 +195,7 @@ export class GitHubPrService implements vscode.Disposable { } /** 全量取数(分页 ≤ MAX_PAGES)→ 建索引 + 写缓存;任一页失败 → null(不缓存,留待重试)。 */ - private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise { + private async fetchAll(remote: GitHubRemote, provider: GitHubAuthProviderId, repoRoot: string): Promise { const generation = this.fetchGeneration; // 取数期间的代次(登录态/取数源配置变化后作废) // remotes 与 repoRoot 同时采样(首个 await 之前):完成后才读会取到切库后他仓的列表,错配写入缓存。 // name + URL 解析 host/owner/repo:供 Tier a 身份校验(不可解析 → null;可解析但 host 与本仓 @@ -221,7 +221,7 @@ export class GitHubPrService implements vscode.Disposable { // 网络已尝试后的失败统一走 fail():失败走**上下文一致的**旧缓存(cacheMatches 单一判定, // 旧索引续显而非闪烁隐现),无缓存才无徽标;失败退避只在代次未变时武装(invalidate 刚复位 // 的退避不被在途旧代次的失败重新盖住——登录后/取数源切换后立即可重取)。 - const fail = (): RemotePrIndex | null => { + const fail = (): BranchPrIndex | null => { if (generation === this.fetchGeneration) { this.failureCooldownUntil = Date.now() + FAILURE_BACKOFF_MS; } diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index 3c617ba..4a20e68 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -6,7 +6,7 @@ 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 { BranchPrIndex } from '../../engine/pr/match'; import type { PullRequestVM } from '../../engine/pr/types'; import { mdTooltip } from './tree-tooltip'; @@ -73,7 +73,8 @@ function prThemeColor(pr: PullRequestVM): vscode.ThemeColor { * 四段分组:Favorites(收藏置顶,参考 JetBrains 分组设计)/ Local / Remote / Tags。 * 构造函数自订阅 service.onDidChange + favorites.onDidChange 做即时刷新。 * - * PR 徽标:远程分支行右侧 description 显示关联 PR(`#138 Merged`,GitHub 图标着色)。 + * PR 徽标:本地与远程分支行右侧 description 显示关联 PR(`#138 Merged`,GitHub 图标着色)。 + * 远程短名走索引三级匹配,本地裸名走 Tier b/c(不剥前缀、无 fork 身份校验,语义见 engine/pr/match)。 * 数据经 {@link GitHubPrService} 异步拉取(TTL 缓存):首轮无徽标先行渲染,索引就绪且与快照 * 不同才 fire 一次二次渲染(引用相等守卫 + 服务端同实例返回 → 无刷新循环)。 */ @@ -87,7 +88,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider /** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */ private groupingEnabled: boolean; /** PR 匹配索引快照(null = 无数据:未登录/非 GitHub/未就绪 → 行为与无徽标一致)。 */ - private prIndex: RemotePrIndex | null = null; + private prIndex: BranchPrIndex | null = null; private prEnsureInFlight: Promise | undefined; /** PR 取数代次:切库/登录态/配置任一变化即 +1;在途取数完成时代次已变则弃结果并对当前态补拉。 */ private prKickEpoch = 0; @@ -378,16 +379,21 @@ 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; + // PR 徽标:远程短名三级匹配 / 本地裸名 Tier b/c(含活动分支与 Favorites 条目,同路径自动生效)。 + const pr = !isTag + ? element.remote + ? this.prIndex?.lookup(ref.shortName) + : this.prIndex?.lookupLocal(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, pr); item.contextValue = isTag ? 'hyperGit.tag' : element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch'; item.tooltip = this.tooltip(ref, active, fav, pr); const icon = isTag ? 'tag' : element.remote ? 'cloud' : 'git-branch'; - // 活动分支 = charts.blue(与 Log 本地分支 chip 同语义,全局一致);收藏(非活动)= charts.yellow; - // 其余远程分支带 PR → 按状态着色(远程行 active 恒 false,实际即收藏优先于 PR 色)。 + // 活动分支 = charts.blue(与 Log 本地分支 chip 同语义,全局一致;本地活动行的 PR 状态色此时 + // 由 description 徽标文本承载);收藏(非活动)= charts.yellow;其余分支带 PR → 按状态着色 + // (远程行 active 恒 false,实际即收藏优先于 PR 色)。 const color = active ? new vscode.ThemeColor('charts.blue') : fav @@ -412,7 +418,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider } } if (pr) { - parts.push(prBadge(pr)); // `#138 Merged` —— 远程行右侧 PR 徽标 + parts.push(prBadge(pr)); // `#138 Merged` —— 分支行右侧 PR 徽标 } return parts.join(' '); } diff --git a/src/engine/pr/match.ts b/src/engine/pr/match.ts index b343bae..428465a 100644 --- a/src/engine/pr/match.ts +++ b/src/engine/pr/match.ts @@ -1,8 +1,9 @@ /** - * 远程分支 → Pull Request 匹配(纯逻辑,零 vscode 依赖)。 + * 分支 → Pull Request 匹配(纯逻辑,零 vscode 依赖;远程短名 / 本地裸名双查询入口)。 * - * 匹配对象是「剥掉 remote 前缀后的分支路径」与 PR 的 `headRefName`(head 仓库内的分支名)。 - * 前缀剥离复用 {@link resolveRemoteBranch} 的最长前缀匹配(remote 名可含 `/`)。三级优先: + * 远程短名(`origin/feature` 形态)匹配对象是「剥掉 remote 前缀后的分支路径」与 PR 的 + * `headRefName`(head 仓库内的分支名)。前缀剥离复用 {@link resolveRemoteBranch} 的最长前缀 + * 匹配(remote 名可含 `/`)。三级优先: * - **a(fork 全名 + remote 身份校验)**:shortName 恰为 `${headOwner}/${headRefName}`,且首段 * 是真实存在的 remote、其 URL 解析 host/owner/repo 与本仓 host 及 PR 的 head owner/head repo * 一致(owner/repo 大小写不敏感、host 与本仓同实例;fork 惯例保留 base 仓库名,同 host+owner+repo @@ -18,6 +19,11 @@ * 分支名保持大小写敏感。 * * 同一 headRefName 多条 PR(复用分支名)取 `updatedAt` 最近一条。 + * + * 本地裸名({@link BranchPrIndex.lookupLocal}):**不剥前缀、无 Tier a**——本地分支名不携带 + * remote 身份(无 URL 可作 fork 身份校验,本地名形如 `owner/branch` 不得被当作 fork 全名), + * 只按完整裸名走 Tier b 同仓 → Tier c 唯一兜底;本地名 `origin/feature` 只匹配 headRefName + * 字面同名的 PR,绝不剥离 `origin` 错配 `feature` 的 PR。 */ import { resolveRemoteBranch } from '../ref/remote-ref'; @@ -41,9 +47,12 @@ export interface PrIndexInit { readonly remotes: readonly PrRemoteInfo[]; } -/** 预计算索引;Branches 树逐行 O(1) 查询。 */ -export interface RemotePrIndex { +/** 预计算索引;Branches 树逐行 O(1) 查询(远程短名与本地裸名双入口,同一份 Map)。 */ +export interface BranchPrIndex { + /** 远程分支短名(如 `origin/feature`)→ PR:剥前缀后 Tier a/b/c 三级匹配。 */ lookup(shortName: string): PullRequestVM | undefined; + /** 本地分支裸名(无 remote 前缀)→ PR:完整裸名 Tier b → Tier c 唯一(不剥前缀、无 Tier a)。 */ + lookupLocal(branchName: string): PullRequestVM | undefined; } /** `updatedAt` 近者胜(解析失败按 -∞ 处理,恒不优于合法值)。 */ @@ -53,9 +62,9 @@ function newer(a: PullRequestVM, b: PullRequestVM): PullRequestVM { return (Number.isNaN(ta) ? -Infinity : ta) >= (Number.isNaN(tb) ? -Infinity : tb) ? a : b; } -/** 构建索引:三个 Map 预计算,lookup 侧零遍历。owner 段统一折叠小写(GitHub owner 大小写不敏感, - * `headRepositoryOwner.login` 返回规范形而远程 URL 侧原样),`headRefName`/branch 保持大小写敏感。 */ -export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): RemotePrIndex { +/** 构建索引:三个 Map 预计算,lookup / lookupLocal 侧零遍历。owner 段统一折叠小写(GitHub owner + * 大小写不敏感,`headRepositoryOwner.login` 返回规范形而远程 URL 侧原样),`headRefName`/branch 保持大小写敏感。 */ +export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): BranchPrIndex { // Tier a:`owner/branch` 全名(headOwner 缺失的 PR 不参与;owner 段小写键)。 const byForkFullName = new Map(); // Tier b:同仓 PR 按 headRefName。 @@ -124,6 +133,16 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): } return all[0]; }, + // 本地裸名:不剥前缀(本地名可合法为 `origin/feature`,剥了就错配 `feature` 的 PR)、 + // 无 Tier a(本地名不承载 remote 身份,无法作 fork 身份校验)——与 lookup 的本质行为分界。 + lookupLocal(branchName: string): PullRequestVM | undefined { + const sameRepo = bySameRepoBranch.get(branchName); + if (sameRepo) { + return sameRepo; + } + const all = byBranchAll.get(branchName); + return all?.length === 1 ? all[0] : undefined; + }, }; } @@ -131,3 +150,8 @@ export function buildPrIndex(prs: readonly PullRequestVM[], init: PrIndexInit): export function matchRemoteBranchPr(shortName: string, init: PrIndexInit, prs: readonly PullRequestVM[]): PullRequestVM | undefined { return buildPrIndex(prs, init).lookup(shortName); } + +/** 单发便捷形态(= buildPrIndex(prs, init).lookupLocal(branchName)),主供测试与零星调用。 */ +export function matchLocalBranchPr(branchName: string, init: PrIndexInit, prs: readonly PullRequestVM[]): PullRequestVM | undefined { + return buildPrIndex(prs, init).lookupLocal(branchName); +} diff --git a/tests/unit/pr-match.test.ts b/tests/unit/pr-match.test.ts index b6c9860..8a223cd 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, type PrRemoteInfo } from '../../src/engine/pr/match'; +import { buildPrIndex, matchLocalBranchPr, matchRemoteBranchPr, type PrRemoteInfo } from '../../src/engine/pr/match'; import type { PullRequestVM } from '../../src/engine/pr/types'; function pr(overrides: Partial = {}): PullRequestVM { @@ -183,3 +183,54 @@ describe('buildPrIndex / matchRemoteBranchPr', () => { expect(matchRemoteBranchPr('mylogin/feature', { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('mylogin', null, null, null)] }, prs)).toBeUndefined(); }); }); + +describe('buildPrIndex / matchLocalBranchPr(本地裸名)', () => { + it('Tier b:同仓 headRefName 与本地裸名精确命中(含 / 的本地名)', () => { + const prs = [pr({ headOwner: 'acme', headRefName: 'dependabot/npm_and_yarn/x', number: 138, state: 'MERGED' })]; + const hit = matchLocalBranchPr('dependabot/npm_and_yarn/x', ACME, prs); + expect(hit?.number).toBe(138); + expect(hit?.state).toBe('MERGED'); + }); + + it('Tier c:非同仓 fork PR 且裸名候选唯一 → 兜底命中(fork / gh pr checkout 工作流)', () => { + const prs = [pr({ headOwner: 'contributor', headRefName: 'patch-1', number: 9 })]; + expect(matchLocalBranchPr('patch-1', ACME, prs)?.number).toBe(9); + }); + + it('Tier c 歧义(两个 fork 同名分支)→ 不匹配;追加同仓 PR 后 Tier b 命中(b 优先于 c)', () => { + const fork = [pr({ headOwner: 'fork1', headRefName: 'patch-1', number: 10 }), pr({ headOwner: 'fork2', headRefName: 'patch-1', number: 11 })]; + expect(matchLocalBranchPr('patch-1', ACME, fork)).toBeUndefined(); + const withSameRepo = [...fork, pr({ headOwner: 'acme', headRefName: 'patch-1', number: 12 })]; + expect(matchLocalBranchPr('patch-1', ACME, withSameRepo)?.number).toBe(12); + }); + + it('无 Tier a 钉子:本地名形如 owner/branch 不做 fork 身份匹配(远程行可有徽标、本地行无,预期分歧)', () => { + const prs = [pr({ headOwner: 'mylogin', headRefName: 'feature', number: 7 })]; + const init = { repoOwner: 'acme', repoHost: 'github.com', remotes: [rm('origin', 'acme'), rm('mylogin')] }; + // 远程短名 mylogin/feature 经 Tier a 命中;同一字符串作为本地裸名恒不匹配。 + expect(matchRemoteBranchPr('mylogin/feature', init, prs)?.number).toBe(7); + expect(matchLocalBranchPr('mylogin/feature', init, prs)).toBeUndefined(); + }); + + it('不剥前缀钉子:本地名 origin/feature 只匹配字面同名 PR,绝不剥离错配 feature 的 PR', () => { + const prs = [pr({ headOwner: 'acme', headRefName: 'feature', number: 5 })]; + // remotes 含 origin:若 lookupLocal 误复用剥前缀路径,origin/feature 会错配 #5。 + expect(matchLocalBranchPr('origin/feature', ACME, prs)).toBeUndefined(); + expect(matchLocalBranchPr('feature', ACME, prs)?.number).toBe(5); + }); + + it('大小写:owner 折叠不敏感命中;分支名大小写敏感不命中', () => { + const prs = [pr({ headOwner: 'Acme-Inc', headRefName: 'feature', number: 14 })]; + expect(matchLocalBranchPr('feature', { repoOwner: 'acme-inc', repoHost: 'github.com', remotes: [rm('origin', 'acme')] }, prs)?.number).toBe(14); + expect(matchLocalBranchPr('Feature', ACME, prs)).toBeUndefined(); + }); + + it('空 PR 列表 → 恒不匹配;buildPrIndex 索引上 lookupLocal 与单发形态等价', () => { + expect(matchLocalBranchPr('feature', ACME, [])).toBeUndefined(); + const prs = [pr({ headRefName: 'a', number: 1 }), pr({ headRefName: 'b', number: 2 })]; + const idx = buildPrIndex(prs, ACME); + expect(idx.lookupLocal('a')?.number).toBe(1); + expect(idx.lookupLocal('b')?.number).toBe(2); + expect(idx.lookupLocal('c')).toBeUndefined(); + }); +});