From 1c6969b5602e550196371123bf5a745862269031 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 12:14:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(Log):=20=E6=8F=90=E4=BA=A4=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=98=BE=E7=A4=BA=20GitHub=20CI=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=88=E7=BB=BF=E5=8B=BE/=E7=BA=A2?= =?UTF-8?q?=E5=8F=89=20+=20=E6=82=AC=E5=81=9C=20Tooltip=20=E6=98=8E?= =?UTF-8?q?=E7=BB=86=EF=BC=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 engine/ci(纯逻辑):GitHub 远程解析、GraphQL 批量查询构造、状态归一化与聚合、响应解析 - 新增 adapter/ci:复用 VS Code 内置 GitHub 认证、按 oid 缓存、批量 + 限流冷却、openExternal 反 SSRF - 扩展协议:log/requestCi、log/ciData、log/ciMeta、log/openExternal、log/ciSignIn - Log webview:提交行最右侧 CI 图标(懒加载仅取可见行)+ 自定义 Tooltip 浮层(明细 + 失败原因 + 跳转链接) - 配置 hyperGit.log.ci.{enabled,remote,provider} 与命令 hyperGit.ci.signIn - 新增单测 53 项覆盖 engine 层(ci-remote-parser/graphql-query/rollup/model) 🤖 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/.agents/knowledge-map.md | 1 + docs/README.md | 3 + docs/features/log-ci-status.md | 77 +++++ package.json | 17 ++ src/adapter/ci/github-auth.ts | 58 ++++ src/adapter/ci/github-ci-service.ts | 416 ++++++++++++++++++++++++++++ src/adapter/webview/log-webview.ts | 271 +++++++++++++++++- src/engine/ci/graphql-query.ts | 80 ++++++ src/engine/ci/model.ts | 100 +++++++ src/engine/ci/remote-parser.ts | 92 ++++++ src/engine/ci/rollup.ts | 100 +++++++ src/engine/ci/types.ts | 42 +++ src/extension.ts | 8 +- src/shared/protocol.ts | 22 +- tests/unit/ci-graphql-query.test.ts | 55 ++++ tests/unit/ci-model.test.ts | 106 +++++++ tests/unit/ci-remote-parser.test.ts | 77 +++++ tests/unit/ci-rollup.test.ts | 98 +++++++ 18 files changed, 1619 insertions(+), 4 deletions(-) create mode 100644 docs/features/log-ci-status.md create mode 100644 src/adapter/ci/github-auth.ts create mode 100644 src/adapter/ci/github-ci-service.ts create mode 100644 src/engine/ci/graphql-query.ts create mode 100644 src/engine/ci/model.ts create mode 100644 src/engine/ci/remote-parser.ts create mode 100644 src/engine/ci/rollup.ts create mode 100644 src/engine/ci/types.ts create mode 100644 tests/unit/ci-graphql-query.test.ts create mode 100644 tests/unit/ci-model.test.ts create mode 100644 tests/unit/ci-remote-parser.test.ts create mode 100644 tests/unit/ci-rollup.test.ts diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 91ece86..735db45 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -16,6 +16,7 @@ ## 项目文档(docs/) - [文档中心](../docs/README.md) — 文档与调研资产总索引。 +- [Log 视图 CI 状态](../docs/features/log-ci-status.md) — 按提交显示 GitHub CI 最终状态(绿勾/红叉 + Tooltip 明细):认证、限流、懒加载、边界与配置。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [IDEA 功能复刻矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**)。 diff --git a/docs/README.md b/docs/README.md index 350ff66..f4bc25c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,9 @@ - [工程实施方案](./architecture/engineering-plan.md) — 全链路调研结论 + 路径 B 架构 + M0-M5 里程碑路线图 + 风险与验证(**开发蓝图**)。 - [IDEA 功能复刻矩阵](./requirements/idea-feature-matrix.md) — 56 个原子功能点 / 8 组 + CheckinHandler 生命周期(**验收基线**)。 +## 功能文档 +- [Log 视图 CI 状态](./features/log-ci-status.md) — 按提交显示 GitHub CI 最终状态(绿勾/红叉 + 悬停 Tooltip 明细):认证、限流、懒加载、边界与配置。 + ## 发布说明 - [Release Notes 目录](./releases/README.md) — 各正式版发布说明(GitHub Release 正文单一事实源);最新:[v0.0.1 首个 MVP](./releases/v0.0.1.md)。 diff --git a/docs/features/log-ci-status.md b/docs/features/log-ci-status.md new file mode 100644 index 0000000..af25484 --- /dev/null +++ b/docs/features/log-ci-status.md @@ -0,0 +1,77 @@ +# Log 视图 CI 状态(GitHub Actions / Commit Status) + +> 在 Log 视图(`hyperGit.log`,IDEA 风格提交图)每条提交上显示其 **CI 最终状态**:绿勾=通过、红叉=失败、 +> 运行中=黄色旋转;悬停图标以浮层 Tooltip 展示「各项检查 + 未通过原因 + 运行链接」,效果对标 IntelliJ IDEA / GitHub。 +> 数据源为 GitHub(Checks API + Commit Status),按 origin 远程主机自动判定 github.com / GitHub Enterprise。 + +## 数据流 + +```mermaid +flowchart LR + subgraph Git["本地"] + A["git log → GraphRowVM"] --> B["图先渲染(CI 不阻塞)"] + end + subgraph WV["Webview(可见行懒加载)"] + B --> C["滚动收集未知 hash"] + C -->|"防抖 200ms"| D["log/requestCi"] + end + subgraph Host["Extension Host"] + D --> E["解析 origin 远程\nowner/repo/host"] + E --> F["vscode.authentication\n取 token(repo 范围)"] + F --> G["GraphQL 批量 ≤100 oid\nstatusCheckRollup"] + G --> H["按 oid 缓存\n终态永久 / pending 30s"] + end + H -->|"log/ciData"| I["webview 就地重绘图标"] + I --> J["悬停 → Tooltip 明细"] + J -->|"log/openExternal"| K["host 校验主机后\nopenExternal"] + style A fill:#1f6feb,color:#fff + style G fill:#238636,color:#fff + style H fill:#8957e5,color:#fff + style J fill:#d29922,color:#fff +``` + +## 认证与安全 + +- 复用 **VS Code 内置 GitHub 认证**(`vscode.authentication`),凭证由编辑器托管,**绝不经过 chat / 日志 / webview**。 +- 范围 `repo`:覆盖私有仓库的 Checks(Actions)+ Commit Status 读取(`repo:status` 不覆盖 Checks API)。 +- **静默优先**:加载时 `getSession({createIfNone:false})` 仅复用已有会话,**绝不自动弹窗**;仅当用户点击工具栏「登录 GitHub」 + 按钮时才以 `{createIfNone:true}` 触发原生授权 UI。未登录 → 显示登录提示、不渲染图标、不发请求。 +- **反 SSRF**:Tooltip 的跳转链接(`detailsUrl` / `targetUrl`,属「观察内容」)由 host 校验 `https` 且主机 ∈ {仓库主机、`*.github.com`} 后才 `openExternal`。 + +## 限流与性能 + +- **懒加载、仅取可见行**:webview 虚拟滚动只渲染 ~50 行,滚动时收集未知 hash(防抖 200ms)批量请求。1000 条提交永不触发 1000 次请求。 +- **批量 GraphQL**:单次最多 100 个 oid 的 `statusCheckRollup`(别名批量),并发上限 2。 +- **缓存**:终态(success/failure)整会话缓存(提交 CI 结果不可变);pending/unknown 30s TTL,运行中构建会逐步刷新为终态。 +- **限流冷却**:读取响应 `rateLimit{remaining,resetAt}` 与 `Retry-After`;剩余点数 <100 或 403 时进入冷却,期间只走缓存并给出一次性提示。 +- **降级**:未推送提交(远程无此 object)/ 无 CI 配置 → 不渲染图标;网络错误不缓存、下次滚动重试;断网/限流不崩溃、建图正常。 + +## 边界行为 + +| 场景 | 表现 | +|---|---| +| 未推送 / 本地提交 | 无图标(远程无对应 object → unknown) | +| 仓库无 CI 配置 | 无图标(rollup 为 null → unknown) | +| 非 GitHub 远程(GitLab 等) | 功能隐藏(零图标、零请求) | +| GitHub Enterprise | 按 origin 主机自动判定,使用 `github-enterprise` provider(需用户已配置 `github-enterprise.uri`) | +| 窄屏(.narrow 隐藏 author/date) | CI 图标例外保留可见 | + +## 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `hyperGit.log.ci.enabled` | `true` | 总开关。关闭后零图标、零请求。 | +| `hyperGit.log.ci.remote` | `""` | 查询用的远程名;留空=自动(优先 `origin`),多远程可指定如 `upstream`。 | +| `hyperGit.log.ci.provider` | `auto` | `auto`(按主机判定)/ `github.com` / `github-enterprise`。 | + +## 实现 + +- 引擎层(纯逻辑,Vitest 可测):[`engine/ci/`](../../src/engine/ci) — `remote-parser.ts`(URL→坐标/端点)、`graphql-query.ts`(批量查询构造)、`rollup.ts`(状态归一化/聚合)、`model.ts`(响应解析)、`types.ts`。 +- 适配层(唯一触碰 vscode/网络):[`adapter/ci/`](../../src/adapter/ci) — `github-auth.ts`(认证)、`github-ci-service.ts`(缓存/批量/限流/降级/openExternal)。 +- 协议:[`shared/protocol.ts`](../../src/shared/protocol.ts) — `log/requestCi`、`log/ciData`、`log/ciMeta`、`log/openExternal`、`log/ciSignIn`。 +- 渲染:[`adapter/webview/log-webview.ts`](../../src/adapter/webview/log-webview.ts) — 内联 JS 的懒加载、图标槽位(提交行最右侧)、自定义 Tooltip 浮层。 + +## 验证 + +1. `pnpm run check-types` + `pnpm run lint` + `pnpm run test:unit`(含 `tests/unit/ci-*.test.ts`)全绿。 +2. Extension Development Host(F5)在 GitHub 仓库:未登录见「登录 GitHub」提示 → 点击原生授权 → 可见行右侧渐次出现图标;悬停红叉见明细 + 链接;点击打开 run;未推送/非 GitHub → 无图标;断网/限流不崩溃。 diff --git a/package.json b/package.json index 8091f85..a664ab0 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ { "command": "hyperGit.commit", "title": "提交", "category": "Hyper Git", "icon": "$(check)" }, { "command": "hyperGit.commitAndPush", "title": "提交并推送", "category": "Hyper Git", "icon": "$(cloud-upload)" }, { "command": "hyperGit.refreshLog", "title": "刷新 Log", "category": "Hyper Git", "icon": "$(refresh)" }, + { "command": "hyperGit.ci.signIn", "title": "登录 GitHub 查看 CI 状态", "category": "Hyper Git" }, { "command": "hyperGit.refreshBranches", "title": "刷新 Branches", "category": "Hyper Git", "icon": "$(refresh)" }, { "command": "hyperGit.logFilterAuthor", "title": "按作者过滤", "category": "Hyper Git" }, { "command": "hyperGit.logFilterPath", "title": "按路径过滤", "category": "Hyper Git" }, @@ -344,6 +345,22 @@ "type": "boolean", "default": false, "markdownDescription": "启用 AI 代理能力(M5,当前仅预留接缝,暂不生效)。" + }, + "hyperGit.log.ci.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "在 Log 视图每条提交上显示 CI 最终状态(GitHub Actions + Commit Statuses)。绿勾=通过、红叉=失败、悬停查看各项检查与未通过原因。需远程为 GitHub。" + }, + "hyperGit.log.ci.remote": { + "type": "string", + "default": "", + "markdownDescription": "查询 CI 状态使用的远程名(留空=自动,优先 `origin`)。多远程仓库可指定如 `upstream`。" + }, + "hyperGit.log.ci.provider": { + "type": "string", + "enum": ["auto", "github.com", "github-enterprise"], + "default": "auto", + "markdownDescription": "CI 数据来源。`auto` 按 origin 主机判定(github.com → github.com,其余 → GitHub Enterprise)。" } } } diff --git a/src/adapter/ci/github-auth.ts b/src/adapter/ci/github-auth.ts new file mode 100644 index 0000000..5489a88 --- /dev/null +++ b/src/adapter/ci/github-auth.ts @@ -0,0 +1,58 @@ +/** + * GitHub 认证(adapter 层,唯一触碰 vscode.authentication)。 + * + * 复用 VS Code 内置 GitHub 认证 provider,凭证由编辑器托管,绝不经过 chat/日志。 + * - `repo` 范围:覆盖私有仓库的 Checks(Actions)+ Commit Status 读取(`repo:status` 不覆盖 Checks)。 + * - 静默 `peek`(createIfNone:false):仅复用已存在会话,绝不弹窗;`signIn`(createIfNone:true)仅在 + * 用户显式点击「登录」时由命令触发。provider id 随主机定(github.com→`github`,GHE→`github-enterprise`)。 + */ + +import * as vscode from 'vscode'; + +export type GitHubAuthProviderId = 'github' | 'github-enterprise'; + +/** 读 CI(私有仓库的 Checks + Statuses)所需范围。 */ +const GITHUB_SCOPES = ['repo'] as const; + +export class GitHubAuth { + /** 已缓存的会话(undefined 表示「已探测且无会话」;has() 区分「未探测」与「无会话」避免重复 getSession)。 */ + private readonly cached = new Map(); + + constructor(disposables: vscode.Disposable[]) { + disposables.push( + vscode.authentication.onDidChangeSessions((e) => { + const id = e.provider.id; + if (id === 'github' || id === 'github-enterprise') { + this.cached.delete(id as GitHubAuthProviderId); + } + }), + ); + } + + /** 静默探测:仅返回已存在会话,永不弹窗。provider 未注册(GHE 未配置)时抛错,由调用方捕获。 */ + async peek(provider: GitHubAuthProviderId): Promise { + if (this.cached.has(provider)) { + return this.cached.get(provider); + } + const session = await vscode.authentication.getSession(provider, [...GITHUB_SCOPES], { createIfNone: false }); + this.cached.set(provider, session); + return session; + } + + /** 交互式登录:显示原生授权 UI。仅在用户显式手势触发时调用。 */ + async signIn(provider: GitHubAuthProviderId): Promise { + try { + const session = await vscode.authentication.getSession(provider, [...GITHUB_SCOPES], { createIfNone: true }); + this.cached.set(provider, session); + return session; + } catch { + // 用户取消授权:保持未登录,不抛错。 + return undefined; + } + } + + /** 失效缓存(401 时调用,强制下次重新解析会话)。 */ + invalidate(provider: GitHubAuthProviderId): void { + this.cached.delete(provider); + } +} diff --git a/src/adapter/ci/github-ci-service.ts b/src/adapter/ci/github-ci-service.ts new file mode 100644 index 0000000..c0c7fdb --- /dev/null +++ b/src/adapter/ci/github-ci-service.ts @@ -0,0 +1,416 @@ +/** + * GitHub CI 服务(adapter 层,唯一触碰 vscode/网络)。 + * + * 编排:解析 origin 远程 → GitHub 坐标 → 取 token → GraphQL 批量取 `statusCheckRollup` → + * 按 oid 缓存(终态永久 / pending 30s TTL)→ 回填 webview。CI 懒加载、仅取可见行、终态整会话缓存, + * 从根本上规避 1000 提交触发 1000 请求的限流风暴。失败一律降级为「无图标 / 走缓存」,绝不阻塞建图。 + * + * 安全:token 仅用于 Authorization 头,不入日志/webview;`openExternal` 校验主机属 GitHub(反 SSRF, + * detailsUrl 属观察内容不盲信)。 + */ + +import * as vscode from 'vscode'; +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 type { CiStatusVM } from '../../engine/ci/types'; + +/** 单次 GraphQL 拉取的 oid 上限(与可见窗口粒度对齐,文档体积与单点成本均衡)。 */ +const MAX_OIDS_PER_QUERY = 100; +/** GraphQL 并发上限(滚动连发时保护 GitHub)。 */ +const MAX_CONCURRENT = 2; +/** 单请求超时(ms)。 */ +const REQUEST_TIMEOUT_MS = 15_000; +/** pending / unknown 缓存 TTL(ms)—— 运行中或未推送的提交短时刷新。 */ +const PENDING_TTL_MS = 30_000; +/** 剩余点数低于此阈值进入限流冷却(直到 resetAt)。 */ +const RATE_FLOOR = 100; + +export interface CiServiceStatus { + /** 远程为 GitHub 且功能启用:可显示图标/发请求。 */ + readonly available: boolean; + /** 远程是 GitHub 但未授权:webview 显示「登录」提示。 */ + readonly needsAuth: boolean; + /** 软错误摘要(限流/运行时无 fetch)。 */ + readonly error?: string; +} + +interface RemoteCacheEntry { + readonly repoRoot: string; + readonly remote: GitHubRemote | null; +} + +interface CacheEntry { + readonly vm: CiStatusVM; + readonly expires: number; // Infinity 表示整会话 +} + +/** 简易异步信号量(控制并发请求数)。 */ +class Semaphore { + private active = 0; + private readonly waiters: Array<() => void> = []; + constructor(private readonly max: number) {} + async acquire(): Promise { + if (this.active < this.max) { + this.active++; + return; + } + await new Promise((resolve) => this.waiters.push(resolve)); + } + release(): void { + if (this.waiters.length > 0) { + this.waiters.shift()!(); // 直接把槽位移交给等待者,active 不变 + } else { + this.active--; + } + } +} + +export class GitHubCiService implements vscode.Disposable { + private remoteCache: RemoteCacheEntry | null = null; + private readonly cache = new Map(); + private readonly inflight = new Map>(); + private cooldownUntil = 0; + private readonly semaphore = new Semaphore(MAX_CONCURRENT); + private readonly abortController = new AbortController(); + private readonly disposables: vscode.Disposable[] = []; + + constructor( + private readonly service: GitRepositoryService, + private readonly auth: GitHubAuth, + private readonly logger: Logger, + ) { + this.disposables.push( + // 配置变更(开关/provider/远程)后重算可用性。 + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('hyperGit.log.ci')) { + this.remoteCache = null; + } + }), + ); + } + + // ─── 对外能力 ──────────────────────────────────────────────────────────── + + /** 当前仓库的 CI 能力 + 授权态(廉价:复用缓存会话,不阻塞建图)。 */ + async status(): Promise { + if (!this.enabled()) { + return { available: false, needsAuth: false }; + } + const remote = this.resolveRemote(); + const provider = remote ? this.providerIdFor(remote) : null; + if (!remote || !provider) { + return { available: false, needsAuth: false }; + } + if (typeof globalThis.fetch !== 'function') { + return { available: false, needsAuth: false, error: '当前运行时无全局 fetch' }; + } + try { + const session = await this.auth.peek(provider); + return { available: true, needsAuth: !session, error: this.cooldownError() }; + } catch { + // provider 未注册(GHE 未配置 github-enterprise.uri)→ 功能休眠,不报错。 + return { available: false, needsAuth: false }; + } + } + + /** 批量取 CI 状态(缓存优先 + in-flight 去重 + 批量 GraphQL)。 */ + async getStatuses(hashes: readonly string[]): Promise> { + const result = new Map(); + if (hashes.length === 0 || !this.enabled()) { + return result; + } + const remote = this.resolveRemote(); + const provider = remote ? this.providerIdFor(remote) : null; + if (!remote || !provider) { + return result; + } + + const uniq = [...new Set(hashes)]; + const toAwait: Array<[string, Promise]> = []; + const toFetch: string[] = []; + for (const h of uniq) { + const cached = this.readCache(h); + if (cached) { + result.set(h, cached); + continue; + } + const existing = this.inflight.get(h); + if (existing) { + toAwait.push([h, existing]); + } else { + toFetch.push(h); + } + } + + if (toFetch.length > 0 && !this.inCooldown()) { + for (const batch of this.chunk(toFetch, MAX_OIDS_PER_QUERY)) { + const batchPromise = this.runBatch(remote, provider, batch); + for (const h of batch) { + const p = batchPromise.then((m) => m.get(h)); + this.inflight.set(h, p); + toAwait.push([h, p]); + } + } + } + + if (toAwait.length === 0) { + return result; + } + const settled = await Promise.all(toAwait.map(async ([h, p]) => [h, (await p) as CiStatusVM | undefined] as const)); + for (const [h, vm] of settled) { + this.inflight.delete(h); + if (vm) { + this.writeCache(h, vm); + result.set(h, vm); + } + } + return result; + } + + /** 交互式登录(命令触发)。 */ + async signIn(): Promise { + const remote = this.resolveRemote(); + const provider = remote ? this.providerIdFor(remote) : null; + if (!remote || !provider) { + return false; + } + try { + const session = await this.auth.signIn(provider); + return !!session; + } catch { + return false; + } + } + + /** 校验后打开外部链接(反 SSRF:仅放行 GitHub 主机)。 */ + async openExternal(rawUrl: string): Promise { + let uri: vscode.Uri; + try { + uri = vscode.Uri.parse(rawUrl, true); + } catch { + return; + } + if (uri.scheme !== 'https') { + return; + } + const host = uri.authority.toLowerCase(); + const remote = this.resolveRemote(); + const allowed = + host === 'github.com' || + host.endsWith('.github.com') || + (!!remote && (host === remote.host || host === `api.${remote.host}`)); + if (!allowed) { + this.logger.warn(`拒绝打开非 GitHub 链接:${rawUrl}`); + return; + } + await vscode.env.openExternal(uri); + } + + dispose(): void { + this.abortController.abort(); + this.inflight.clear(); + this.disposables.forEach((d) => d.dispose()); + } + + // ─── 配置 / 远程解析 ────────────────────────────────────────────────────── + + private enabled(): boolean { + return vscode.workspace.getConfiguration('hyperGit').get('log.ci.enabled', true); + } + + private providerConfig(): 'auto' | 'github.com' | 'github-enterprise' { + return vscode.workspace.getConfiguration('hyperGit').get<'auto' | 'github.com' | 'github-enterprise'>( + 'log.ci.provider', + 'auto', + ); + } + + private remoteNameConfig(): string { + return vscode.workspace.getConfiguration('hyperGit').get('log.ci.remote', '') ?? ''; + } + + /** 远程 → 认证 provider(依据配置与主机;不匹配返回 null → 功能休眠)。 */ + private providerIdFor(remote: GitHubRemote): GitHubAuthProviderId | null { + const cfg = this.providerConfig(); + if (cfg === 'github.com') { + return remote.isGitHubDotCom ? 'github' : null; + } + if (cfg === 'github-enterprise') { + return remote.isGitHubDotCom ? null : 'github-enterprise'; + } + // auto:信任远程主机探测(github.com → github,其余 → github-enterprise)。 + return authProviderId(remote); + } + + /** 解析当前仓库远程为 GitHub 坐标(按 repoRoot 缓存;优先配置名 → origin → 首个可解析)。 */ + private resolveRemote(): GitHubRemote | null { + const repoRoot = this.service.repoRoot ?? ''; + if (this.remoteCache && this.remoteCache.repoRoot === repoRoot) { + 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 ?? ''; + const ordered: Array<{ fetchUrl?: string; pushUrl?: string }> = []; + if (preferred) { + const hit = remotes.find((r) => r.name === preferred); + if (hit) { + ordered.push(hit); + } + } + const origin = remotes.find((r) => r.name === 'origin'); + if (origin) { + ordered.push(origin); + } + ordered.push(...remotes); + for (const cand of ordered) { + remote = parseGitHubRemote(pick(cand)); + if (remote) { + break; + } + } + } + this.remoteCache = { repoRoot, remote }; + return remote; + } + + // ─── 缓存 / 限流 ───────────────────────────────────────────────────────── + + private readCache(hash: string): CiStatusVM | undefined { + const entry = this.cache.get(hash); + if (!entry) { + return undefined; + } + if (entry.expires !== Infinity && Date.now() > entry.expires) { + this.cache.delete(hash); + return undefined; + } + return entry.vm; + } + + private writeCache(hash: string, vm: CiStatusVM): void { + const terminal = vm.state === 'success' || vm.state === 'failure'; + this.cache.set(hash, { vm, expires: terminal ? Infinity : Date.now() + PENDING_TTL_MS }); + } + + private inCooldown(): boolean { + return Date.now() < this.cooldownUntil; + } + + private cooldownError(): string | undefined { + if (!this.inCooldown()) { + return undefined; + } + const secs = Math.ceil((this.cooldownUntil - Date.now()) / 1000); + return `GitHub 限流,约 ${secs}s 后恢复`; + } + + // ─── 取数 ──────────────────────────────────────────────────────────────── + + private chunk(arr: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + out.push(arr.slice(i, i + size)); + } + return out; + } + + /** 单批 GraphQL 取数(带并发闸、超时、401/403/限流处理)。失败返回空 Map(不缓存,留待重试)。 */ + private async runBatch(remote: GitHubRemote, provider: GitHubAuthProviderId, oids: string[]): Promise> { + const map = new Map(); + if (oids.length === 0) { + return map; + } + const fetchImpl = globalThis.fetch; + if (typeof fetchImpl !== 'function') { + this.logger.warn('当前运行时无全局 fetch,CI 功能不可用'); + return map; + } + const token = await this.acquireToken(provider); + if (!token) { + return map; // 未授权:不缓存,待登录后重试 + } + + const { query } = buildCiQuery({ owner: remote.owner, name: remote.repo, oids }); + const endpoint = graphqlEndpoint(remote); + const body = JSON.stringify({ query, variables: { owner: remote.owner, name: remote.repo } }); + + await this.semaphore.acquire(); + try { + const res = await this.fetchWithTimeout(fetchImpl, endpoint, { + method: 'POST', + headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'hyper-git-vscode' }, + body, + }); + if (res.status === 401) { + this.auth.invalidate(provider); // token 失效,强制下次重解析会话 + return map; + } + if (res.status === 403) { + const retryAfter = res.headers.get('retry-after'); + if (retryAfter) { + this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + Number(retryAfter) * 1000); + } + return map; + } + let json: unknown; + try { + json = await res.json(); + } catch { + return map; + } + const errors = (json as { errors?: unknown[] })?.errors; + if (Array.isArray(errors) && errors.length > 0) { + this.logger.warn(`CI 查询返回错误:${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); + } + for (const [h, vm] of parseCiResponse(json, oids)) { + map.set(h, vm); + } + } catch (e) { + if ((e as Error)?.name !== 'AbortError') { + this.logger.warn(`CI 请求失败:${(e as Error).message ?? e}`); + } + } finally { + this.semaphore.release(); + } + 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); + return session?.accessToken ?? null; + } catch { + return null; + } + } +} diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 85c4891..6c2094a 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -7,7 +7,10 @@ import { DEFAULT_LANE_PALETTE } from '../../engine/log/graph-color'; import { computeGraphLayout, maxLanes } from '../../engine/log/graph-layout'; import { parseLogLines } from '../../engine/log/log-line'; import { buildLogArgs, type LogScope } from '../../engine/log/log-query'; +import type { GitHubCiService } from '../ci/github-ci-service'; import type { + CiMetaVM, + CiStatusVM, GraphRowVM, LogCommitFileItem, LogGraphState, @@ -84,7 +87,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter private refreshTimer: ReturnType | undefined; private readonly disposables: vscode.Disposable[] = []; - constructor(private readonly service: GitRepositoryService) { + constructor(private readonly service: GitRepositoryService, private readonly ciService: GitHubCiService) { // 兜底实时刷新:git 状态变化(commit/checkout 等)防抖重拉首页。 let t: ReturnType | undefined; this.disposables.push( @@ -162,6 +165,15 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter void this.handleCommitMenu(msg.payload.hash); } break; + case 'log/requestCi': + void this.handleRequestCi(msg.payload.hashes); + break; + case 'log/openExternal': + void this.ciService.openExternal(msg.payload.url); + break; + case 'log/ciSignIn': + void this.handleCiSignIn(); + break; } } @@ -189,6 +201,47 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter repoRoot: this.service.repoRoot ?? '', }; this.post({ type: 'log/graphData', payload: state }); + // CI 元信息异步随附(不阻塞建图):远程为 GitHub 才启用,未授权则提示登录。 + void this.pushCiMeta(); + } + + /** 推送 CI 能力/授权态(status() 廉价:复用缓存会话)。失败静默回退为不可用。 */ + private async pushCiMeta(): Promise { + if (!this.view) { + return; + } + let meta: CiMetaVM; + try { + const s = await this.ciService.status(); + meta = { available: s.available, needsSignIn: s.needsAuth, error: s.error }; + } catch { + meta = { available: false, needsSignIn: false }; + } + if (this.view) { + this.post({ type: 'log/ciMeta', payload: meta }); + } + } + + /** 懒加载可见行 CI(webview 滚动按需请求),取数后守卫 view 仍存在再回填。 */ + private async handleRequestCi(hashes: readonly string[]): Promise { + if (hashes.length === 0) { + return; + } + const map = await this.ciService.getStatuses(hashes); + if (!this.view || map.size === 0) { + return; + } + const rec: Record = {}; + for (const [hash, vm] of map) { + rec[hash] = vm; + } + this.post({ type: 'log/ciData', payload: { map: rec } }); + } + + /** 用户点击「登录 GitHub 查看 CI」:走原生授权,完成后刷新 CI 元信息。 */ + private async handleCiSignIn(): Promise { + await this.ciService.signIn(); + await this.pushCiMeta(); } private async loadMore(cursor: number): Promise { @@ -373,6 +426,39 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod #details .file .nm { overflow: hidden; text-overflow: ellipsis; } #empty { padding: 16px; text-align: center; opacity: 0.6; font-size: 12px; } #spinner { position: absolute; bottom: 6px; right: 8px; font-size: 11px; opacity: 0.6; display: none; } +/* ── CI 状态图标(提交行最右侧,固定 16px 槽位,保证 author/date 列对齐)── */ +.ci { flex: 0 0 16px; width: 16px; display: inline-flex; align-items: center; justify-content: center; } +.ci svg { display: block; shape-rendering: geometricPrecision; pointer-events: none; } +.ci-success { color: var(--vscode-testing-iconPassed, #3fb950); } +.ci-failure { color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground, #f85149)); } +.ci-pending { color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground, #d29922)); } +.ci:not(.ci-empty):hover { filter: brightness(1.15); } +.ci:not(.ci-empty):focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; border-radius: 3px; } +/* narrow 模式隐藏 author/date,但 CI 图标例外保留(核心信号)。 */ +#viewport.narrow .ci { display: inline-flex; } +@keyframes ci-rot { to { transform: rotate(360deg); } } +.ci-spin { transform-origin: 50% 50%; animation: ci-rot 1s linear infinite; } +@media (prefers-reduced-motion: reduce) { .ci-spin { animation: none; } } +.ci-signin { display: none; background: transparent; border: 1px solid var(--vscode-button-border, var(--vscode-input-border, transparent)); color: var(--vscode-textLink-foreground); font-size: 10px; padding: 1px 6px; border-radius: 3px; cursor: pointer; opacity: 0.85; } +.ci-signin:hover { opacity: 1; background: var(--vscode-list-hoverBackground); } +/* ── CI Tooltip(自定义浮层,置于 #rows 之外,虚拟滚动重写不销毁)── */ +#ci-tip { position: fixed; z-index: 50; display: none; max-width: 360px; min-width: 220px; max-height: 320px; overflow: hidden; background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border, rgba(128,128,128,.3))); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,.35); font-size: 12px; } +#ci-tip.show { display: flex; flex-direction: column; } +#ci-tip .tip-h { padding: 7px 10px; font-weight: 600; border-bottom: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); display: flex; align-items: center; gap: 6px; } +#ci-tip .tip-h .g { flex: 0 0 14px; display: inline-flex; } +#ci-tip .tip-list { overflow-y: auto; max-height: 240px; padding: 2px 0; } +#ci-tip .tip-row { display: flex; align-items: flex-start; gap: 7px; padding: 4px 10px; cursor: pointer; } +#ci-tip .tip-row:hover { background: var(--vscode-list-hoverBackground); } +#ci-tip .tip-row .g { flex: 0 0 14px; display: inline-flex; margin-top: 1px; } +#ci-tip .tip-row .nm { flex: 1 1 auto; min-width: 0; overflow: hidden; } +#ci-tip .tip-row .nm .desc { display: block; font-size: 11px; opacity: 0.7; white-space: normal; word-break: break-word; margin-top: 1px; } +#ci-tip .tip-foot { padding: 6px 10px; border-top: 1px solid var(--vscode-editorHoverWidget-border, rgba(128,128,128,.2)); } +#ci-tip .tip-foot a { color: var(--vscode-textLink-foreground); cursor: pointer; text-decoration: none; } +#ci-tip .tip-foot a:hover { text-decoration: underline; } +#ci-tip .g-success { color: var(--vscode-testing-iconPassed, #3fb950); } +#ci-tip .g-failure { color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground, #f85149)); } +#ci-tip .g-pending { color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground, #d29922)); } +#ci-tip .g-skipped { color: var(--vscode-descriptionForeground, #8b949e); } @@ -382,6 +468,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod +
@@ -389,6 +476,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod
加载中…
+