diff --git a/.gitignore b/.gitignore index b69c98478..a0edaf810 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Logs logs *.log +# 但 docs/logs/ 是版本化的阶段开发日志(不是运行日志),必须入库: +# 上面的 `logs` 规则会匹配任意层级的 logs 目录,因此需要显式把该目录本身重新包含进来 +# (父目录被排除时,git 无法单独重新包含目录内的文件)。 +!docs/logs/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/docs/logs/2026-09-20-stage-1-repository-health-core.md b/docs/logs/2026-09-20-stage-1-repository-health-core.md new file mode 100644 index 000000000..fb47ecee9 --- /dev/null +++ b/docs/logs/2026-09-20-stage-1-repository-health-core.md @@ -0,0 +1,139 @@ +# 阶段日志 1:Repository Health Core(v0.9.0) + +- 日期:2026-09-20 +- 分支:`plugin-system-v0-9`(基线 `b06a347`,已快进到 `upstream/plugin-system-v0-9`) +- 依据:[`开发守则.md`](../../../开发守则.md) §1 与 [`docs/plans/2026-09-17-product-roadmap.md`](../plans/2026-09-17-product-roadmap.md) §4 +- 阶段提交:`feat: add repository health facts` +- 代码量:29 个文件,+2112 / −31 → 按仓库既有节奏(1166–7110 行的功能提交对应 minor 提升)取 minor 版本号 `0.9.0` + +## 1. 目标与边界 + +提供**客观**的 Repository Health Facts,不给统一总分: + +- Core 只输出可验证事实与保守观测(`archived` / `disabled` / `no-releases` / `no-recent-activity`)。 +- 不提供 0–100 健康分数,不做「健康 / 不健康」结论——主观评分属于插件。 +- 明确拒绝「最近提交少 = 不健康」:`no-recent-activity` 只是中性观测,阈值 12 个月仅用于展示。 +- 未知事实一律为 `undefined` / `null`,绝不猜测(例如未同步过 Release 时不声称「没有 Release」)。 + +## 2. 统一模型 + +新增 [`src/types/health.ts`](../../src/types/health.ts): + +- `RepositoryHealthSnapshot`:roadmap 建议模型 + `fork` / `isTemplate` / `releasesFetched` / + `releasesPerYear` / `latestStableVersion` / `latestPrereleaseVersion` / `ageDays` / + `daysSinceLastPush` / `signals`。 +- `RepositoryHealthFact`:三态取值语义 —— `undefined` = 未知,`null` = 已知为空(如无 license), + 有值 = 已知;并带 `group` / `kind` / `source`(`repository` / `releases` / `enrichment`)。 +- `RepositoryHealthGroup`:固定 `activity` → `maintenance` → `community` → `maturity`。 + +推导集中在 [`src/utils/repositoryHealth.ts`](../../src/utils/repositoryHealth.ts),纯函数、无网络请求: + +| 能力 | 说明 | +|---|---| +| `deriveRepositoryHealthSnapshot` | 由 `Repository` + 本地 `Release[]`(+ 可选 enrichment)推导全部事实 | +| `deriveRepositoryHealthSignals` | 4 种保守观测,顺序固定,`since` 仅在时间型观测上有值 | +| `groupRepositoryHealthFacts` | 展开为 UI 分组视图,不含任何格式化(留给 UI / 后续 i18n) | +| `isArchivedRepository` / `hasRecentActivity` / `hasDeclaredLicense` | 列表筛选谓词,与 UI 口径同源 | + +**没有新增持久化 Store slice,也没有 Store migration**:快照是派生投影,不进入 +`Repository` 实体持久化路径,因此不触碰 Issue #304 的后端同步哈希契约。 + +## 3. 让事实真正可达:同步层的最小改动 + +`/user/starred` 原始响应本就带 `archived` / `disabled` / `fork` / `is_template` / +`open_issues_count` / `default_branch`,但 `Repository` 类型没有声明,且后端不存储这些列。 +按「先确认安全性再动手」的顺序处理: + +1. [`src/types/index.ts`](../../src/types/index.ts):把这 6 个字段加入 `Repository`(可选)。 + 它们是运行时对象里**已经存在**的字段,因此类型补全不改变 `JSON.stringify` 的任何字节。 +2. [`src/utils/repositoryMerge.ts`](../../src/utils/repositoryMerge.ts):同名字段**成对**加入 + `CLIENT_ONLY_REPOSITORY_FIELDS`(不参与后端同步指纹)与 `LOCAL_REPOSITORY_FIELDS` + (拉取时保留本地值)。只加一边会破坏既有不变式——只加 CLIENT_ONLY 会在每次拉取时被清空, + 只加 LOCAL 会保留但触发多余的「已变化」判定。 +3. [`src/features/repositories/hooks/useSearchActions.ts`](../../src/features/repositories/hooks/useSearchActions.ts): + 星标同步的既有仓库字段白名单加入这 6 个字段(归档状态会随上游变化), + 源缺失时回落到本地已知值,避免事实退化成「未知」。 +4. [`src/services/githubApi.ts`](../../src/services/githubApi.ts):详情路径(REST + GraphQL) + 捕获这些字段;GraphQL 片段补上 `isArchived` / `isDisabled` / `isFork` / `isTemplate` / + `openIssues` / `defaultBranchRef`,否则 GraphQL 路径拿不到值。字段在 + `GitHubRepoDetailRead` 中保持可选,避免破坏既有测试夹具。 + +后端 `server/` 与 `cloudflare-worker/` **未改动**:后端不存这些列,因此 MCP 侧对 +「未知」与「未归档」保持严格区分(见 §5)。 + +## 4. 复用面 + +| 复用方 | 落点 | +|---|---| +| UI | 新增 [`RepositoryHealthPanel.tsx`](../../src/components/RepositoryHealthPanel.tsx),接入 [`RepositoryReleaseSheet.tsx`](../../src/components/RepositoryReleaseSheet.tsx)(唯一同时持有 repository 与实时 releases 的界面)。分组展示 + 保守观测徽章 + 「以上为客观事实,不含健康总分」脚注 | +| 筛选 | `SearchFilters` 新增 `healthArchived` / `healthRecentActivity` / `healthHasLicense`;[`SearchBar.tsx`](../../src/components/SearchBar.tsx) 新增「仓库健康事实」筛选区,`clearFilters` / 激活计数同步 | +| 排序 | 新增 `sortBy: 'created'`(成熟度视角),`getSortValue` 与排序下拉同步 | +| Discovery / AI | [`aiService.ts`](../../src/services/aiService.ts) 两处 repoInfo 模板(自定义 / 内置提示)插入客观事实摘要,并显式标注「中性,不代表质量结论」 | +| MCP | [`electron/repoHealth.js`](../../electron/repoHealth.js) + [`server/src/mcp/repoHealth.ts`](../../server/src/mcp/repoHealth.ts) 镜像同一算法;`buildRepoEvidence` 输出 `health` 块,`gsm_search_repos` / `gsm_list_repos_by_category` 暴露 3 个 health 筛选与 `created` 排序 | +| Plugin API | [`pluginProtocol.js`](../../electron/plugins/pluginProtocol.js) `sanitizeRepository` 追加 6 个状态字段 + `has_fetched_releases`(全部属于既有 `repositories:read` 权限范围,不新增能力) | + +MCP 侧修正了一处**既有的事实性错误**:原本 `evidence.repository.archived` 恒为 `null`, +并在 `limitations[0]` 固定声明 `archived is not stored locally`。现在只有在记录里确实 +不存在该布尔值时才保持 `null` 并声明限制;一旦有值就照实上报。该限制串因此改为条件输出, +既有断言的夹具不含该字段,所以断言语义不变。 + +### 三份镜像实现是刻意的 + +`src/utils/repositoryHealth.ts`(ESM/TS)、`electron/repoHealth.js`(CommonJS)、 +`server/src/mcp/repoHealth.ts`(ESM/TS)必须是三份:Electron 侧是 CJS 且 +`electron-builder.yml` 不打包 `src/`,server 的 `rootDir: "src"` 禁止 import 应用源码树。 +这与仓库既有的 `mcpDiscovery.js` / `server/src/mcp/repoSearch.ts` 镜像模式一致。 +[`server/tests/mcp/parity.test.ts`](../../server/tests/mcp/parity.test.ts) 新增两条用例锁定 +Electron 与后端两份输出**逐字段相等**,并锁定「未知保持未知」。 + +## 5. 刻意未做 + +- 不引入需要联网补全的事实(contributors、closed issues、Security Policy、CI、README/文档、 + 默认分支最近提交):模型已预留字段与 `enrichment` 来源,但在本阶段不联网获取, + UI 显示为「未知」。这样避免了额外的 GitHub 请求预算与限流风险,且离线可用。 +- **列表筛选不包含依赖 Release 的事实**(是否有 Release、最新版本):`applyRepoFilters` + 拿不到 Release 数组,为它接一个全量 `releases` 订阅会造成整个搜索栏频繁重渲染。 + 这些事实在 Health 面板、MCP 证据与插件快照中可用。 +- `gsm_vector_search` 不暴露 health 筛选:向量检索有独立的 Worker 契约,且其注释明确 + 不应假装提供精确的 corpus-wide filtered topK。保留其已批准的输入面。 +- 不做健康总分、不做插件评分界面(交给插件生态)。 + +## 6. 验证 + +| 关卡 | 结果 | +|---|---| +| `node scripts/check-boundaries.cjs` | 通过(无分层违规) | +| `npm run typecheck` | 通过 | +| `npm run lint` | 通过 | +| `npx vitest run` | 102 文件 / 1012 用例,16 失败 —— 与改动前基线**完全相同**(`RepositoryCard`、`RepositoryCard.lazyReadme`、`ReadmeModal`、`ForkTimeline` 的 jsdom 5s 超时,属环境慢导致,非本次改动) | +| `npm run test:electron:mcp` | 24/24 通过(含证据与工具输入面断言) | +| `npm run test:electron:plugins` | 91/91 通过 | +| `npm run test:update-version` | 10/10 通过 | +| server 测试 | **未能运行**:`npm ci` 在 `better-sqlite3` 原生编译处失败(本机缺 C++ 构建工具链),属既有环境限制;server 侧改动已逐行复核,并新增 parity 用例待 CI 执行 | + +新增/更新的测试: + +- `src/utils/repositoryHealth.test.ts`(22 例):推导、三态语义、观测顺序、`presto` 之类 + 预发布误判防护、筛选谓词。 +- `src/components/RepositoryHealthPanel.test.tsx`(6 例):分组、无总分声明、未知不猜测、 + 无 Release 数据时不报「No releases」。 +- `electron/repoHealth.test.js`(6 例):锁定 Electron 镜像算法。 +- `server/tests/mcp/parity.test.ts`(+2 例):跨运行时逐字段一致。 + +`RepositoryReleaseSheet.test.tsx` 的 Release 条目查询改为限定在 `release-list` 容器内, +因为 Health 面板同样会显示最新稳定版本 tag,全局查询会与之串台。 + +## 7. 版本号说明 + +根 `package.json` 此前停留在 `0.8.1`,而 `version-info.xml` 也没有 `0.9.0` 记录—— +插件平台(`c53c07b`…`b06a347`)虽然在提交信息里自称 v0.9.0,却从未落版本号与更新日志。 +本阶段既然要发布 `0.9.0`,就把 0.8.1 之后实际进入代码的插件平台内容与本次 Health 事实 +一并写入该版本的 changelog,避免用户看到「0.9.0 只包含 Health 事实」的失真描述。 + +## 8. 下一阶段 + +阶段 2:Installable Asset Detection(roadmap §5 / 开发守则 §2)—— +统一识别当前设备可安装的 Release 资产,输出 `InstallableAsset`(platform / architecture / +packageType / confidence / reason),排除 source code、checksum、signature、symbols、debug、 +blockmap 等非安装资产,复用既有 `detectAssetPlatform` 与 Smart Release 的平台/架构判断, +不确定时展示多个候选且不声称安装包安全。 diff --git a/docs/logs/2026-09-20-stage-2-installable-asset-detection.md b/docs/logs/2026-09-20-stage-2-installable-asset-detection.md new file mode 100644 index 000000000..eb763214d --- /dev/null +++ b/docs/logs/2026-09-20-stage-2-installable-asset-detection.md @@ -0,0 +1,133 @@ +# 阶段日志 2:Installable Asset Detection(v0.10.0) + +- 日期:2026-09-20 +- 分支:`plugin-system-v0-9`(基线 `8b1bfeb`) +- 依据:[`开发守则.md`](../../../开发守则.md) §2 与 [`docs/plans/2026-09-17-product-roadmap.md`](../plans/2026-09-17-product-roadmap.md) §5 +- 提交:`feat: detect installable release assets` +- 代码量:+约 1200 行(含测试)→ minor 提升至 `0.10.0` + +## 1. 目标与边界 + +统一回答「这个 Release 里哪个资产可以在当前设备上安装」,并把它做成可被 Release 视图、 +Discovery、My Apps、AI、MCP 与插件复用的一份结果。 + +明确不做(roadmap §5.3): + +- 不下载后执行、不自动运行安装程序。 +- 不因为扩展名像安装包就断言软件安全——UI 文案显式说明「未验证安装包安全性」。 +- 不把 ZIP 一律当作可安装软件(见 §3 的平台判定)。 +- 不自动选择来源不明的第三方镜像:下载仍走既有 GitHub allowlist 链路。 + +## 2. 统一模型 + +[`src/types/installableAsset.ts`](../../src/types/installableAsset.ts):`InstallableAsset` +(assetId / fileName / downloadUrl / size / platform / architecture / packageType / +confidence / reason)与 `InstallableAssetDetectionResult`(`matches` + `excluded[]`)。 + +模型里显式保留 `excluded`(资产 id + 文件名 + 原因):这样 UI 能回答「为什么某个资产没被推荐」, +而不是让排除静默发生。这直接对应用户「始终可以手动选择其他 Release Asset」的前提—— +用户需要知道还有什么、以及为什么没被选中。 + +## 3. 识别规则 + +[`src/utils/installableAssets.ts`](../../src/utils/installableAssets.ts),纯函数、无网络: + +| 环节 | 做法 | +|---|---| +| 平台 | **复用** [`detectAssetPlatform`](../../src/utils/releaseAssets.ts) 与它导出的 `OS_TOKEN_PLATFORM` 词表,不另写平台表 | +| 包类型 | 新增后缀表(长后缀优先):`.exe .msi .zip .7z .dmg .pkg .deb .rpm .AppImage .tar.gz .apk .aab` | +| 架构 | 新增词表:`x64`(`x86_64`/`amd64`/`x64`/`win64`)、`arm64`(`aarch64`/`arm64`)、`x86`(`ia32`/`i386…i686`/裸 `x86` 且后面不是 `64`)、`universal`(`universal`/`universal2`/`multiarch`) | +| 排除 | 源代码归档、校验和、签名、调试符号、Electron blockmap、SBOM | +| 置信度 | 决定性扩展名 + 已知架构 = `high`;只满足其一 = `medium`;容器格式且架构未知 = `low` | + +三条刻意的判定规则: + +1. **`win32` 不是 32 位标记**。Electron 用 `win32` 命名 Windows 构建,把它当 x86 会让 + `app-win32-x64.zip` 这种常见命名被误判。参考实现(`examples/plugins/smart-release-recommender/worker.js`) + 的注释也是这么写的,这里保持一致。 +2. **文件名声明多个平台直接排除**(`project-win32-linux-x64.zip`),与参考实现一致; + **扩展名与文件名冲突也排除**(`app-1.2.0-linux.dmg`)——宁可排除并说明,也不要挑一个可能装错的包。 +3. **容器格式缺平台标记时不猜平台**(`myapp-1.0.zip`):只在 `content_type` 提供 MIME 证据时 + 才回落(复用 `detectAssetPlatform` 的最后一层)。其余情形排除并说明原因, + 资产表里仍可手动下载。 + +不确定时的处理是「并列候选」:设备架构未知就不做架构过滤(x64 与 arm64 同时列出), +文件名同时声明多架构就不猜架构、降级为 `medium` 并注明。 + +## 4. 当前设备识别 + +[`src/utils/deviceTarget.ts`](../../src/utils/deviceTarget.ts):`detectDevicePlatformSync()` +(`navigator.userAgentData.platform` → `navigator.platform` → UA 字符串)与 +`resolveDeviceArchitecture()`(high-entropy hints,带进程内缓存)。 + +两个刻意的选择: + +- **不新增 Electron IPC**。渲染进程用 `navigator.userAgentData` 就能拿到宿主 OS/CPU 架构, + 而 Web 版本来就没有 Electron 进程。为读一个平台号新增 IPC 会扩大 Host 接口面, + 与「不扩大 Electron 权限边界」相悖。插件运行时的 `hostEnvironment {os, arch}` 保持不变,各管一摊。 +- **架构拿不到就承认拿不到**。high-entropy hints 只能异步取;Firefox/Safari 或 API 被拒时 + 返回 `undefined`,调用方据此放弃架构过滤,而不是假定 x64。 + +## 5. UI + +[`InstallableAssetRecommendation.tsx`](../../src/components/InstallableAssetRecommendation.tsx) +接入 `RepositoryReleaseSheet` 的资产页签,位于插件推荐块之下、资产表之上(该组件同时持有 +`release` 与 `onDownload`,无需新数据源): + +- 首选候选 + 「下载此版本」按钮(**必须点击**,绝不自动下载)、置信度徽章、平台/架构/包类型/大小。 +- 其他候选列表,每个都可单独下载。 +- 排除说明:列出「源代码 / 校验和 / 签名 / 调试符号 / blockmap / 其他平台或架构」的排除数量。 +- 无适配资产时整块**不渲染**(不制造噪音)。 + +下载动作**复用既有链路**:`buildReleaseDownloadLinks` 生成 `ReleaseDownloadLink`, +再交给 `RepositoryReleaseSheet` 的 `downloadAsset`(RPC / 认证下载 / 后端代理 / window.open)。 +检测结果只提供 `assetId`,按 id 回查 link,因此没有第二套下载逻辑,也没有改动 +`ReleaseDownloadLink` 的形状(它的测试做整数组 `toEqual`)。 + +顺带把 `formatFileSize` 从 `RepositoryReleaseSheet` 提到 +[`src/utils/formatBytes.ts`](../../src/utils/formatBytes.ts),让推荐块与资产表用同一套显示口径。 + +## 6. 刻意的分歧与未做 + +- **不复用 `PRESET_FILTERS`**:它把 `zip` / `tar.gz` 归入 Source,且用朴素子串匹配 + (`win` 会命中 `darwin`),与「可安装软件识别」的目标直接冲突。既有资产筛选器保持原样。 +- **不改参考实现**:`examples/plugins/smart-release-recommender/worker.js` 里的架构/排除/打分 + 逻辑无法反向依赖 `src/`(沙箱装的是打包后的插件,且插件不应读 Core 内部)。 + 它是给第三方插件看的参考实现,保留自己的小词表是合理分歧; + Core 侧的 Host 实现已在模块注释里标明对应关系。 +- **本阶段不接入 Discovery / My Apps / 插件 / MCP**:开发守则 §2 的验收项只要求模型、识别规则、 + 候选展示与手动选择,这三项已完成。把结果推给插件的 MCP 快照与插件 release 载荷需要先定下 + 「服务端运行时的设备是谁」(自托管后端可能跑在容器里,报容器平台会误导用户), + 这件事值得单独一个小 PR;`hasInstallableAsset()` 已作为仓库级入口导出,供 Discovery + 与批量导入预览直接复用。 + +## 7. 验证 + +| 关卡 | 结果 | +|---|---| +| `node scripts/check-boundaries.cjs` | 通过 | +| `npm run typecheck` | 通过 | +| `npm run lint` | 通过 | +| `npx vitest run` | 105 文件 / 1062 用例,16 失败 —— 与阶段 1 及改动前基线**同一集合**(4 个重型 jsdom 组件测试的 5s 超时) | +| `npm run test:electron:mcp` / `test:electron:plugins` / `test:update-version` | 本阶段未触碰 electron / server / 脚本,保持阶段 1 的通过状态 | + +新增测试: + +- [`installableAssets.test.ts`](../../src/utils/installableAssets.test.ts)(30 例):各平台包类型、 + 6 类排除、多平台/扩展名冲突、架构冲突与通用包、未知架构并列候选、AAB 只识别、 + MIME 回落、排序稳定性、空输入、`hasInstallableAsset`。 +- [`deviceTarget.test.ts`](../../src/utils/deviceTarget.test.ts)(12 例):三级平台回退、 + high-entropy 架构映射与缓存、API 缺失/被拒/32 位 ARM 时返回 `undefined`。 +- [`InstallableAssetRecommendation.test.tsx`](../../src/components/InstallableAssetRecommendation.test.tsx)(6 例): + 点击才下载、复用 link 模型、安全免责声明、无候选不渲染、候选与排除说明、无法识别设备时并列候选。 + +`RepositoryReleaseSheet.test.tsx` 无需改动即通过(推荐块不渲染被排除的资产名, +平台徽章断言仍由资产表的 `.asset-platform-badge` 满足)。 + +## 8. 下一阶段候选 + +按开发守则的推荐顺序,下一阶段可以是: + +1. `feat: add batch repository URL extraction`(§6 前半,纯逻辑、可测试、风险低); +2. 或把可安装资产结果接入平台感知 Discovery(§8)与统一快照(插件/MCP), + 但需要先明确服务端运行时的设备归属。 diff --git a/docs/logs/2026-09-20-stage-3-main-unification.md b/docs/logs/2026-09-20-stage-3-main-unification.md new file mode 100644 index 000000000..cea8164a5 --- /dev/null +++ b/docs/logs/2026-09-20-stage-3-main-unification.md @@ -0,0 +1,112 @@ +# 阶段日志 3:main 统一(合并全量功能、删除其余分支、单一工作副本) + +- 日期:2026-09-20 +- 性质:仓库治理操作,**不是**功能阶段;对应「将更全的功能覆盖到 main,删去其他分支,以后统一在 main 上开发」 +- 依据:[`docs/reports/2026-09-20-main-vs-plugin-system-v0-9-merge-analysis.md`](../reports/2026-09-20-main-vs-plugin-system-v0-9-merge-analysis.md) +- 合并提交:`721eb31`(父:`8fab039` 本分支线 + `b08532a` 旧 main) +- 代码量:**0 行源代码变更**(合并结果树与阶段 2 已验证的 v0.10.0 逐字节一致,仅新增分析报告与日志),因此**不提升版本号**,版本仍为 `0.10.0` + +> 编号说明:这是第 3 个阶段日志。下一个功能阶段(批量 Repository 导入的提取与归一化) +> 记为阶段 4,避免编号冲突。 + +## 1. 为什么这步需要单独决策 + +`merge-base main plugin-system-v0-9` **为空**——两条线没有共同祖先:main 独有 1084 个提交、 +本分支独有 1132 个。但内容上 main 是本分支的**真子集**(`git diff --diff-filter=A` = 0 个文件; +main 的独有内容只有 2 个文件 84 净行,且都是 ADR 0001 重构**之前**的旧形态)。 +所以「合并」在这里不是常规操作,必须先把风险量出来。 + +## 2. 实测三种合并方式 + +| 方式 | 命令 | 结果 | 结论 | +|---|---|---|---| +| 普通 merge | `merge-tree --allow-unrelated-histories upstream/main plugin-system-v0-9` | exit=1,222 行冲突 | 不可用 | +| `-X theirs` | 同上追加 `-X theirs` | exit=0,但结果树 `36ab7ab` ≠ 本分支树 | **不可用** | +| `-s ours` | 在本分支上 `git merge -s ours --allow-unrelated-histories upstream/main` | 结果树与本分支逐字节一致 | 采用 | + +`-X theirs` 的陷阱值得记下来:它零冲突、看起来最干净,但 6 个在本分支被**移动过**的文件 +(`src/hooks/useAuthSessionGeneration.ts`、`useBackendLifecycle.*`、`sessionRepository.*`、 +`useWatchedSourcesSync.test.tsx`)在无共同祖先时无法被识别为 rename,于是**新旧两个路径同时存在**, +多出 1032 行重复模块(两套 `sessionRepository` / 两套 lifecycle hook 并存)。这种"合并成功" +会直接破坏构建与测试,比冲突更危险。 + +## 3. 执行序列与验证点 + +```text +git checkout -b tmp-unify plugin-system-v0-9 +git merge -s ours --allow-unrelated-histories upstream/main +# 验证点 1:内容零损失(必须为空) +git diff --stat plugin-system-v0-9 tmp-unify -> 空 +# 验证点 2:两条线都成为祖先 → 推送是快进,不需要 force +git merge-base --is-ancestor plugin-system-v0-9 tmp-unify -> 0 +git merge-base --is-ancestor upstream/main tmp-unify -> 0 +# 验证点 3:在合并结果上跑门禁 +check-boundaries / typecheck / lint / vitest -> 通过,16 个失败与基线同一集合 +git branch -f main tmp-unify +git push upstream main -> b08532a..721eb31(快进) +``` + +合并提交的默认信息被改写成带理由的说明(为什么用 `-s ours`、为什么不需要 force、 +两条历史的可达性),避免以后看到这个"无共同祖先的合并"时一头雾水。 + +## 4. 一个差点毁掉依赖的操作 + +`GithubStarsManager_PluginSystem_latest_pr/node_modules` 是指向主克隆的 **Junction**: + +```text +LinkType: Junction +Target : D:\Code\GithubStarsManager\GithubStarsManager_PluginSystem\node_modules +``` + +`git worktree remove` 的递归删除会**穿透 junction 删掉主克隆的依赖**。因此先只删除链接本身 +(`cmd /c rmdir` 不跟随重解析点),核对主克隆 `node_modules` 仍为 613 项、`.bin/vitest` 存在, +才继续移除 worktree。**Windows 上 git worktree + junction 是危险组合**,以后遇到同类目录先查 +`LinkType`。 + +## 5. 删除范围与历史保全 + +删除前逐一验证可达性,确保删分支不等于丢内容: + +| 分支 | tip | 删除后是否仍可从 main 到达 | +|---|---|---| +| `plugin-system-v0-9` | `8fab039` | **是**(合并提交的第一父) | +| `plugin-manifest-discovery` | `8e89787` | **是**(在 `b08532a` 历史内) | +| `plugin-system` | `a453427` | **否** —— 按决定不留 tag | + +`plugin-system` 上只有 4 个提交不在任何保留历史里:`001f050`、`6803f46`、`10f850a`、`a453427`。 +核对后确认:**内容一个文件都不缺**(`git diff --diff-filter=A plugin-system-v0-9 a453427` = 0), +失去的只是这 4 个提交的历史叙事。当前它们作为对象仍存在于本地(`git cat-file -e` 为真), +但已不可从 `main` 到达,会在 gc 后被回收。 + +同时发现:**`origin` 与 `upstream` 是同一个仓库**——两个 URL 的 `ls-remote` 返回完全相同的 refs +(含 `refs/pull/1/head`),说明 `Khk-NL/GithubStarsManager_PluginSystem` 是该仓库的旧名, +GitHub 做了重定向。因此"推两个远程"实际只需推一次,删远程分支同理。 + +## 6. 最终状态 + +| 项 | 结果 | +|---|---| +| 本地分支 | 只有 `main`(`721eb31`,tracking `upstream/main`) | +| 远程 refs | 只有 `refs/heads/main` + GitHub 的 `refs/pull/1/head` | +| worktree | 只有一个:`D:\Code\GithubStarsManager\GithubStarsManager`,在 `main` | +| 版本 | `0.10.0` | +| 工作区 | 干净 | +| 门禁 | boundaries / typecheck / lint 通过;vitest 与基线同一集合(16 个既有 jsdom 超时) | + +`main` 现在包含:PR #352–#356、完整插件平台、Repository Health 事实(v0.9.0)、 +可安装资产识别(v0.10.0)。 + +## 7. 遗留物(未处理,等你决定) + +1. `origin` 与 `upstream` 同指一个仓库,建议只留一个并改名为 `origin`: + `git remote remove origin && git remote rename upstream origin` +2. 远程仍有 `refs/pull/1/head`(PR #1,来自已删除的 `plugin-manifest-discovery`)。 + 分支已删、内容已并入 main,不再需要时可在 GitHub 关闭该 PR。 +3. 工作区根目录 `D:\Code\GithubStarsManager\` 下还有一个**没有任何提交的空仓库 `.git`** + 和 `.codex/`。前者建议确认后删除,否则在该目录里执行 git 命令会作用到它而不是真正的仓库。 + +## 8. 下一阶段 + +阶段 4:`feat: add batch repository URL extraction`(开发守则 §6 前半)—— +Paste → Extract → Normalize → Deduplicate,把文本 / Markdown / JSON 里的 +`owner/repo` 与 GitHub URL 归一化为候选并去重。联网校验与预览界面按守则的 PR 拆分留到后续阶段。 diff --git a/docs/logs/2026-09-20-stage-4-batch-repository-url-extraction.md b/docs/logs/2026-09-20-stage-4-batch-repository-url-extraction.md new file mode 100644 index 000000000..64b8a4be8 --- /dev/null +++ b/docs/logs/2026-09-20-stage-4-batch-repository-url-extraction.md @@ -0,0 +1,117 @@ +# 阶段日志 4:Batch Repository URL Extraction(v0.11.0) + +- 日期:2026-09-20 +- 分支:`main` +- 依据:[`开发守则.md`](../../../开发守则.md) §6 与 [`docs/plans/2026-09-17-product-roadmap.md`](../plans/2026-09-17-product-roadmap.md) §6 +- 提交:`feat: add batch repository URL extraction` +- 代码量:4 个文件、+约 900 行(含 70 个用例)→ minor 提升至 `0.11.0` + +> 编号说明:阶段 3 是 main 统一(仓库治理,无源码变更)。本阶段是第 4 个阶段日志。 + +## 1. 范围:只做流程的前四步 + +守则 §6 把批量导入拆成两件事,本次只做前一件: + +```text +Paste → Extract → Normalize → Deduplicate │ Resolve GitHub metadata + │ Enrich with local/Core data + │ Review → Batch actions + ← 本阶段(纯函数、无 IO)→ ← 后续阶段(联网 + 预览界面) +``` + +守则的推荐 PR 列表里 `feat: add batch repository URL extraction` 与 +`feat: add batch repository import preview` 本来就是两条,因此这里不碰 UI 与网络。 + +## 2. 模型 + +[`src/types/repositoryImport.ts`](../../src/types/repositoryImport.ts) 按守则的 +`ImportedRepositoryCandidate` 落地,并把 `status` 与 `reason` 的取值域**一次定义完整**, +避免下一阶段再改模型: + +| 字段 | 说明 | +|---|---| +| `status` | `pending` / `resolved` / `duplicate` / `invalid` / `unavailable` | +| `reason` | Extract 阶段产出 `not-a-repository-url`、`malformed-slug`;Resolve 阶段产出 `not-found`、`private-or-inaccessible`、`renamed`、`rate-limited`;Enrich 阶段产出 `already-exists` | +| `originalValue` | 始终保留原始片段,批量导入的每一步都要可回溯 | +| `previousFullName` | 守则要求仓库转移时显示 `old → new` 且**不得静默修改**,旧名字单独留在这里 | + +三处附加字段(守则模型之外,已在注释里标明):`matchedBy`(`github-url` / `bare-slug`)、 +`confidence`(`high` / `low`)、`alreadyStarred`(守则本就有,这里在调用方提供本地集合时即填充)。 + +## 3. 归一化规则 + +URL 形态覆盖:scheme 可省、`www.` 可省、尾斜杠、`.git` 后缀、`#fragment`、`?query`、 +Markdown 链接与尖括号包裹、句末标点。子路径一律丢弃——`/releases/tag/v1.2.0`、 +`/releases/download/v1/app.exe`、`/issues/12`、`/pull/34`、`/tree/main/src`、`/blob/main/a.ts`、 +`/actions`、`/wiki`、`/discussions/5`、`/commit/abc`、`/compare/a...b`、`/stargazers`、 +`/graphs/commit-activity`、`/security/advisories`、`/packages/1` 全部归一到所属仓库。 + +站点功能路径**不静默丢弃**,而是标成 `invalid` 并给出原因,让用户看得见: +`github.com/orgs/…`、`/topics/…`、`/settings/…`、`/features/…`、`/sponsors/…`、 +`/marketplace/…`、`/apps/…`、`/trending`、以及只有一段的 `github.com/`。 + +## 4. 裸 `owner/repo`:不假装有把握 + +散文里 `A/B` 与仓库 slug 天然同形,词表不可能穷尽。这里的做法是**三层抑制 + 一律降级**: + +1. 常见代码目录名:`src/utils`、`docs/plans`、`lib/core`…… + (本仓库自己的文档里就充满这类片段,不排除会大量误报) +2. 常见文件扩展名:`src/utils.ts`、`docs/guide.md`…… +3. 常见词组与单字符段:`and/or`、`TCP/IP`、`read/write`、`24/7`、`i/o`…… + +被接受的裸写法一律标 `confidence: 'low'`,由预览阶段的用户确认。另外先扫 URL 并把匹配区间 +**等长屏蔽**,再扫裸写法,否则 `…/releases/tag/v1.2.0` 里的 `releases/tag`、`tag/v1.2.0` +会被当成两个仓库。 + +## 5. JSON + +递归扫描**所有字符串值**(不扫键名——键名是字段名,扫了只会产生噪声)。 +两层上限:`maxValues`(默认 20000)、`maxDepth`(默认 32)。错误码区分致命与截断: + +- 致命:`json-parse-failed`、`input-too-large` → `candidates` 为空; +- 截断:`too-many-values`、`depth-limit-exceeded` → **保留已扫描到的结果**并附上提示。 + +最后一条是刻意选择:输入很大时把已经识别出来的仓库全丢掉,对用户毫无帮助。 + +## 6. 去重 + +键为仓库名(大小写不敏感),首次出现的保留原大小写并记 `pending`,其后每次追加一条 +`duplicate`(各自带自己的 `originalValue`)。`invalid` 片段同样按原始片段去重, +避免同一段坏 URL 刷屏。 + +## 7. 写测试时抓到的两个真实缺陷 + +1. **`owner/..` 被当成句子标点**:尾部标点清洗(为了 `…/repo.` 这种句末句号)会把 `..` 整段吃掉, + 变成"段数不足 → not-a-repository-url"。改成先按去标点解析,**仅当段数因此不足两段时** + 才用未去标点的原串重试,于是 `owner/repo.` 与 `owner/..` 各自得到正确结果。 +2. **候选顺序不反映输入顺序**:最初先扫完全部 URL 再扫裸写法,于是第 3 行的 `owner/repo` + 被排到第 4 行的 URL 后面。改为两类匹配各带 `index` 合并排序后再去重—— + 预览列表的顺序必须与用户粘贴的顺序一致,否则很难核对。 + +## 8. 验证 + +| 关卡 | 结果 | +|---|---| +| `node scripts/check-boundaries.cjs` | 通过 | +| `npm run typecheck` | 通过 | +| `npm run lint` | 通过 | +| `npx vitest run` | 106 文件 / 1132 用例,16 失败 —— 与基线**同一集合**(4 个重型 jsdom 组件测试的 5s 超时) | +| 本阶段新增用例 | [`repositoryImport.test.ts`](../../src/utils/repositoryImport.test.ts) 70 例全通过 | + +用例覆盖:21 种 URL 子路径形态、11 种不可用路径(含原因码)、Markdown/尖括号/代码块包裹、 +句末标点、10 类裸写法误报的拒绝、URL 屏蔽、大小写不敏感去重、重复 invalid 去重、 +JSON 递归/非字符串/键名/解析失败/值上限/深度上限、空输入、超长输入、`alreadyStarred`、 +以及守则里那段示例的端到端结果。 + +## 9. 刻意未做 + +- 不联网:不校验仓库是否存在、不获取元数据、不判断私有/改名/限流(Resolve 阶段)。 +- 不做预览界面与批量操作(Star / 分类 / Tag / 订阅 Release / 导出 / 发送到插件 / 进入 My Apps)。 +- 不实现 `clipboard` 与 `file` 来源(类型里已预留,复用同一套提取逻辑)。 +- 不读 store:`toLocalRepositoryNameSet` 只做纯函数转换,本地数据由调用方传入。 + +## 10. 下一阶段 + +阶段 5:`feat: add batch repository import preview` —— 预览工作台, +在提取结果之上做 Resolve(GitHub 元数据 + 失败项区分 + 改名/转移展示)与 Enrich +(已 Star / 已在 My Apps),再提供批量操作。 diff --git a/docs/plans/2026-09-17-product-roadmap.md b/docs/plans/2026-09-17-product-roadmap.md new file mode 100644 index 000000000..fac4cb697 --- /dev/null +++ b/docs/plans/2026-09-17-product-roadmap.md @@ -0,0 +1,1137 @@ +# GithubStarsManager 产品路线提案 + +状态:Proposal +日期:2026-09-17 +范围:Repository Health、软件识别与版本追踪、Discovery、插件生态、插件商城和 Android 适配 + +## 1. 产品定位 + +GithubStarsManager 的近期目标不是成为能够静默安装和控制系统软件的通用包管理器,而是逐步发展为: + +> 帮助用户发现、理解、整理、追踪并安全获取 GitHub 软件的项目与版本管理中心。 + +产品继续以 GitHub Repository 为核心实体,在现有 Star 管理、Release 追踪、AI、搜索、MCP 和插件平台之上,逐步增加五类能力: + +1. **理解项目**:提供客观的 Repository Health 和软件类型信息。 +2. **发现软件**:根据 Trending、当前平台、架构和用户历史改善 Discovery。 +3. **整理项目**:支持从文本、JSON 和其他来源批量导入 Repository,并统一去重、预览和管理。 +4. **追踪软件**:关联本机软件与 GitHub Repository,检测新版本并由用户确认下载。 +5. **个性化体验**:支持按需语言包、主题、布局和界面密度配置,同时保持安全边界和可回滚性。 + +自动执行安装程序、静默更新、统一卸载和任意系统权限控制不属于近期目标。 + +## 2. 设计原则 + +### 2.1 Core 与 Plugin 的边界 + +Core 负责稳定、客观、可复用的事实和生命周期: + +- Repository、Release、Asset 和开发者的事实数据。 +- 当前平台和架构。 +- 已安装软件与 Repository 的关联。 +- 已安装版本和最新版本的比较。 +- 搜索、浏览、发现和更新状态。 +- 插件所依赖的稳定 Host API。 + +Plugin 负责主观、可替换或面向特定领域的解释: + +- Repository Health 总分和自定义权重。 +- “是否值得安装”等主观结论。 +- 替代品推荐、AI 分类和领域标签。 +- 特定软件生态的 Release 匹配策略。 +- 自定义导出、报告和 Dashboard。 + +基本规则是: + +> Core 提供事实,Plugin 解释事实;Core 管理状态,Plugin 返回建议。 + +### 2.2 检测、下载与执行分离 + +以下行为必须视为不同风险等级: + +```text +识别软件资产 + → 检测新版本 + → 展示变更和风险 + → 用户确认下载 + → 用户自行运行安装程序 +``` + +近期只做到“用户确认下载”。插件和后台任务不得自动执行安装程序。 + +### 2.3 本地优先和最小权限 + +- 浏览历史、软件关联和版本记录默认保存在本地。 +- 不上传剪贴板内容、安装路径、软件清单和浏览历史。 +- GitHub Token、AI Key 和其他凭据只由宿主管理。 +- 插件只通过 Capability API 获取完成当前操作所需的数据。 +- 后台扫描、联网和通知必须可关闭并说明用途。 + +### 2.4 小步提交 + +每个阶段拆成能够独立审查、测试和回滚的 PR。一个 PR 不同时引入数据模型、系统扫描、安装执行、商城后台和 Android 适配。 + +## 3. 当前基础 + +当前已经具备的基础能力包括: + +- Repository 管理、搜索、分类和 Release 追踪。 +- AI、Web Search 和 GitHub 相关宿主能力。 +- Plugin API v1:Manifest、权限、生命周期、Repository Actions、Processors 和 Exporters。 +- Release Processor、宿主下载和 Smart Release Recommendation 示例。 +- sandboxed 插件页面、CSP 和受限消息 Bridge。 +- 插件隔离存储、日志和设置入口。 + +后续路线应复用这些基础,不重复实现另一套下载、权限、搜索或插件生命周期系统。 + +## 4. Repository Health + +### 4.1 Core Health Facts + +Core 提供可验证的事实,不直接给出统一总分: + +| 分类 | 指标 | +|---|---| +| 状态 | Archived、Disabled、Fork、Template | +| 活跃度 | 最近 push、默认分支最近提交、近期提交数量 | +| Release | 是否存在 Release、最近 Release、Release 频率 | +| 社区 | Stars、Forks、Open/Closed Issues、Contributors | +| 维护 | License、Security Policy、CI、README、文档 | +| 成熟度 | Repository 年龄、Release 数量、最新稳定版本 | + +Health Facts 可用于: + +- Repository 详情页展示。 +- 筛选和排序。 +- Discovery。 +- AI 和 MCP 查询。 +- 插件评分和报告。 + +### 4.2 Health 展示 + +建议先提供分组信号: + +```text +Activity + Latest push 8 days ago + Latest release 21 days ago + +Maintenance + Not archived + CI configured + Security policy available + +Community + Contributors 18 + Open issues 32 +``` + +不得仅依据“最近没有提交”把成熟且稳定的项目标记为不健康。 + +### 4.3 插件扩展 + +插件可以基于 Health Facts 提供: + +- 不同类型项目的评分模型。 +- 自定义风险规则。 +- 团队项目审查报告。 +- “值得安装 / 仅收藏 / 开发资源”等建议。 + +主观评分必须展示规则来源,不能伪装成 Core 的客观结论。 + +## 5. 可安装软件识别 + +### 5.1 目标 + +判断一个 Repository 是否发布了适合当前设备的软件资产,并向 Release、Discovery、My Apps 和插件提供统一结果。 + +首批识别格式: + +| 平台 | 格式 | +|---|---| +| Windows | EXE、MSI、ZIP/7z Portable | +| macOS | DMG、PKG、ZIP、Universal App | +| Linux | DEB、RPM、AppImage、tar.gz | +| Android | APK;AAB 仅识别,不直接安装 | + +统一数据模型示意: + +```ts +interface InstallableAsset { + assetId: number; + fileName: string; + downloadUrl: string; + size: number; + platform: 'windows' | 'macos' | 'linux' | 'android'; + architecture?: 'x64' | 'arm64' | 'x86' | 'universal'; + packageType: + | 'exe' + | 'msi' + | 'zip' + | 'dmg' + | 'pkg' + | 'deb' + | 'rpm' + | 'appimage' + | 'apk'; + confidence: 'high' | 'medium' | 'low'; + reason: string; +} +``` + +### 5.2 识别规则 + +- 结合文件扩展名、文件名、Release 元数据、平台和架构判断。 +- 排除 Source code、checksums、symbols、debug、signature 等非安装资产。 +- 明确拒绝包含冲突平台或架构标记的资产。 +- 不确定时显示多个候选,不伪装成唯一正确答案。 +- 用户始终可以手动选择其他 Release Asset。 + +### 5.3 非目标 + +- 不执行下载后的文件。 +- 不把 ZIP 一律当作可安装软件。 +- 不声称仅凭文件名能够证明软件安全。 +- 不自动选择来源不明的第三方镜像。 + +## 6. My Apps:已安装软件管理 + +### 6.1 第一版范围 + +第一版采用用户主动关联: + +```text +本机软件 + ↕ 用户确认 +GitHub Repository + ↕ Release 比较 +最新版本和下载资产 +``` + +记录字段建议包括: + +```ts +interface LinkedApplication { + id: string; + repositoryFullName: string; + displayName: string; + installedVersion: string | null; + installPath?: string; + platform: string; + architecture?: string; + linkSource: 'manual' | 'detected'; + confidence: 'confirmed' | 'high' | 'possible'; + includePrereleases: boolean; + lastCheckedAt?: string; +} +``` + +用户可以: + +- 手动关联 Repository。 +- 填写或修正当前版本。 +- 查看最新稳定版和更新说明。 +- 选择是否包含 prerelease。 +- 确认后下载推荐资产。 +- 解除关联而不影响已安装软件。 + +### 6.2 更新检测 + +更新检测只比较版本并提醒: + +```text +Installed v1.2.0 +Latest v1.3.1 +Status Update available + +[View changelog] [Download update] +``` + +要求: + +- 定时检查可以关闭并配置频率。 +- 默认不下载、不安装。 +- 无法可靠解析版本时显示 `Unknown`,由用户确认。 +- GitHub API 失败不能把软件误标为已停止维护。 +- 新版本资产不匹配当前平台时只提示 Release,不推荐下载。 + +### 6.3 历史版本和降级 + +先实现 Release 历史版本选择和旧版下载,不直接承诺自动降级: + +- 显示历史 Release、发布日期和 prerelease 状态。 +- 标记当前记录的安装版本。 +- 筛选兼容的历史资产。 +- 下载旧版本前提示配置和数据可能不兼容。 + +界面按钮优先使用 `Download this version`,而不是在尚未执行安装时写成 `Downgrade`。 + +### 6.4 后续本机检测 + +按平台逐步增加只读检测适配器: + +- Windows 卸载注册表。 +- Winget 和 Scoop 已安装列表。 +- macOS Applications 和包信息。 +- Linux dpkg、rpm 和 AppImage 记录。 +- Android PackageManager 提供的允许信息。 + +自动匹配结果必须由用户确认。仅凭软件名称相似不能自动绑定 Repository。 + +### 6.5 软件权限管理边界 + +桌面软件没有统一的跨平台权限系统。近期只管理和展示: + +- 来源 Repository 和 Release。 +- 发布者、版本、下载时间和 SHA-256。 +- 安装路径和检测来源。 +- 是否启用更新检测。 +- 跳转到操作系统的应用权限、启动项、防火墙或卸载设置。 + +不承诺通用地授予或撤销其他软件的文件、网络、摄像头或系统权限。 + +## 7. 平台感知 Discovery + +在现有 Discovery 中逐步增加: + +```text +Discover +├─ Trending +├─ Hot Releases +├─ Popular +├─ Apps for Windows/macOS/Linux/Android +├─ Recently Viewed +└─ Developers +``` +### 7.1 Trending Sync + +现有 Trending 不只作为即时榜单展示,而是作为 Repository Discovery 数据源进行周期性同步。 + +保存独立 Trending Snapshot,不直接污染 Repository 主实体: + +interface TrendingSnapshot { + repositoryFullName: string; + source: 'github-trending'; + period: 'daily' | 'weekly' | 'monthly'; + language?: string; + rank: number; + starsInPeriod?: number; + capturedAt: string; +} +支持: +- Daily / Weekly / Monthly Trending。 +- 按语言筛选。 +- 显示当前排名。 +- 显示上次排名和排名变化。 +- 首次上榜时间。 +- 连续上榜天数。 +- 当前周期新增 Stars。 +- 历史榜单快照。 +Trending Repository 使用统一 Repository 详情数据,不维护独立详情模型: +Trending + ↓ +Repository Metadata + ├─ Health Facts + ├─ Latest Release + ├─ Installable Assets + ├─ Star / Subscription State + ├─ My Apps State + └─ Recently Viewed +Trending 支持以下筛选: +- 当前平台存在兼容资产。 +- Installable only。 +- Not starred。 +- Not seen。 +- Active only。 +- Language。 +- Repository 类型。 +支持将一个或多个 Trending Repository 发送到 Batch Repository Intake,统一预览和执行批量操作。 + +### 7.2 平台筛选 + +- 当前平台和架构默认作为推荐信号,而不是不可取消的硬过滤。 +- 用户可以查看其他平台的软件。 +- 优先展示存在兼容 Release Asset 的 Repository。 +- 清楚区分“有兼容资产”和“仅从主题/描述推测支持”。 + +### 7.3 Recently Viewed + +- 本地记录最近打开的 Repository。 +- 提供清空和关闭历史记录选项。 +- 设置数量或时间上限。 +- 不将浏览历史发送给插件或远端服务,除非用户明确触发相关能力。 + +### 7.4 Hide Seen Repositories + +- Discovery 提供“隐藏已看过”开关。 +- 支持临时显示全部结果。 +- 清空浏览历史后同步解除隐藏。 +- 收藏、安装关联和用户主动固定的 Repository 不应被永久隐藏。 + +### 7.5 Search History 与 Suggestions + +- 现有 Repository Search 已具备搜索历史和 Suggestions;后续在保留现有行为的基础上扩展为全局搜索历史。 +- 全局搜索历史默认本地保存并可关闭、单条删除或全部清空。 +- Suggestions 可以来自本地历史、已有 Star、热门主题和 GitHub 搜索建议。 +- 本地历史不应自动发送给 AI 或第三方搜索服务。 + +### 7.6 Developer Profile + +开发者页面可逐步展示: + +- GitHub 公开资料。 +- 公开 Repository 和常用语言。 +- 用户已收藏或关联的软件。 +- Release 活跃度和相关项目。 + +不要基于贡献量给开发者生成未经解释的信誉或安全分数。 + +### 7.7 Omni Search + +Omni Search 是 Core 提供的全局统一搜索和导航入口,不复制另一套 Repository 搜索实现。第一版通过 `Ctrl+K` 等快捷键打开搜索面板,聚合现有搜索能力: + +```text +Search GithubStarsManager… + +Repositories + Obsidian + facebook/react + +Releases + Magpie v0.12.0 + +Installed Apps + Deskflow — update available + +Developers + rustdesk + +Plugins + Repository Health + +Actions + Sync repositories + Open settings +``` + +第一版搜索范围: + +- Repository 名称、`full_name`、描述、语言、Topic、分类、标签和本地备注。 +- Release 名称、Tag 和标题。 +- Gist 名称和描述。 +- Developer Login 和公开名称。 +- My Apps 名称、当前版本和关联 Repository。 +- 已安装插件名称。 +- 设置页面和常用宿主操作。 + +点击结果后导航到对应页面、详情或经过确认的宿主操作。 + +统一结果类型示意: + +```ts +type OmniSearchResult = { + type: + | 'repository' + | 'release' + | 'gist' + | 'developer' + | 'installed-app' + | 'plugin' + | 'action'; + id: string; + title: string; + subtitle?: string; + score: number; +}; +``` + +第一版不建立大型全文索引。由不同搜索源复用现有数据和搜索函数,再统一评分、分组和限制结果数量: + +```text +用户输入 + ├─ Repository Search + ├─ Release Search + ├─ Gist Search + ├─ Developer Search + ├─ My Apps Search + └─ Plugin / Action Search + ↓ +统一排序和分组 + ↓ +Omni Search Dialog +``` + +结果排序优先级: + +1. Repository `owner/name`、Release Tag、Developer Login 等精确匹配。 +2. 名称前缀和完整名称匹配。 +3. 描述、Topic、分类、标签和备注的关键词匹配。 +4. 可选语义搜索结果。 + +关键词结果应立即显示。语义搜索只在用户停止输入后执行,必须允许降级,服务不可用时不能阻塞普通搜索。语义结果需要明确标记,查询内容不得默认发送给外部 AI Provider。 + +第二阶段可建立本地全文索引,逐步纳入: + +- README。 +- Release Notes。 +- 用户备注。 +- 已缓存的 Repository 摘要和文档。 + +全文索引默认不包含 AI 对话、Token、配置、日志、安装路径、插件隔离存储和私有 Repository 内容。私有内容若未来支持,必须由用户明确启用并仅保存在本地。 + +插件可以在后续提供结构化 Search Provider,但必须遵守: + +- 不能读取全部搜索历史或持续监听用户键盘输入。 +- 只在用户选择对应搜索源或满足最小输入条件后调用。 +- 只能返回经过 schema 校验的结构化结果,不能直接渲染宿主 UI。 +- 联网搜索继续经过 Host Capability、权限检查和用户配置的服务。 +- 单个插件搜索失败不能阻塞其他搜索源。 + +Omni Search MVP 的验收标准: + +- 快捷键可以从主要页面打开和关闭搜索面板。 +- 键盘能够选择并打开结果。 +- 已有 Repository Search 行为不被改变。 +- 各类型结果正确分组,重复结果得到合并。 +- 空查询可以显示最近访问和常用操作,但不触发外部请求。 +- 普通关键词搜索完全可以离线工作。 +- 搜索历史可删除、可关闭且不会自动发送给插件或远端服务。 + +### 7.8 Batch Repository Intake + +用于从任意文本、Markdown 或 JSON 中批量识别 GitHub Repository,并统一解析、去重和预览。 + +支持输入: + +- GitHub Repository URL。 +- 指向 Release、Issue、Pull Request、Tree、Blob 等页面的 GitHub URL,并统一归一为所属 Repository。 +- `owner/repo` 简写。 +- 包含多个 GitHub URL 的普通文本、聊天记录和 Markdown。 +- JSON 数组、对象以及嵌套结构中的 Repository URL 或 `owner/repo` 字符串。 + +第一版不要求用户提供固定 JSON Schema。系统递归扫描 JSON 中的字符串值,识别合法 GitHub Repository 引用。 + +处理流程: + +用户粘贴文本或 JSON + ↓ +提取 GitHub Repository + ↓ +URL 归一化 + ↓ +批次内部去重 + ↓ +与本地 Repository 去重 + ↓ +获取 GitHub Metadata + ↓ +统一 Import Preview + +统一候选数据模型示意: +interface ImportedRepositoryCandidate { + repositoryFullName: string; + source: 'text' | 'json' | 'clipboard' | 'file'; + originalValue: string; + status: + | 'pending' + | 'resolved' + | 'duplicate' + | 'invalid' + | 'unavailable'; + + alreadyStarred?: boolean; +} +Import Preview 至少展示: +- Repository 名称和描述。 +- Stars、Forks、Language、Topics。 +- 是否已经收藏。 +- 是否已存在于当前导入批次。 +- Latest Release。 +- Repository Health 摘要。 +- 是否存在当前平台可安装的 Release Asset。 +- 是否已经关联到 My Apps。 +批量操作可以逐步支持: +- Star / Unstar。 +- 加入分类或标签。 +- 订阅 Release。 +- 加入 My Apps 关联流程。 +- 导出。 +- 执行插件 Repository Action。 +失败项必须明确区分: +- URL 无效。 +- Repository 不存在。 +- Repository 已转移或重命名。 +- Private / inaccessible。 +- GitHub API rate limit。 +- 批次重复。 +- 已存在于本地。 +Repository 重命名或转移时显示原地址和当前地址,不静默替换。 +第一版仅支持粘贴文本和 JSON。CSV、文件导入、浏览器书签和第三方收藏格式后续再扩展。 + + + +## 8. 系统集成 + +### 8.1 Clipboard GitHub URL Detection + +用于识别用户复制的 GitHub Repository、Release 或用户链接。 + +隐私要求: + +- 默认关闭或首次启用时明确说明。 +- 优先仅在应用前台或用户触发粘贴时读取。 +- 本地解析,不上传原始剪贴板内容。 +- 非 GitHub 内容立即丢弃,不写入日志和历史。 +- 提供关闭提示和清空记录入口。 + +### 8.2 Deep Link + +建议逐步支持: + +```text +githubstarsmanager://repo/owner/name +githubstarsmanager://release/owner/name/tag +githubstarsmanager://developer/login +githubstarsmanager://plugins/plugin-id +``` + +要求: + +- 所有参数重新校验,不能把 Deep Link 当作可信输入。 +- 不允许通过链接直接安装插件、执行下载或调用高风险操作。 +- 具有副作用的操作必须再次由用户确认。 + +## 9. Localization 与界面个性化 + +### 9.1 可扩展多语言架构 + +当前中英文界面应逐步重构为统一 i18n 架构,不继续在组件内大量使用: + +```ts +language === 'zh' ? '中文' : 'English' +``` + +改为统一键值访问: + +```ts +t('settings.plugins.title') +``` + +建议目录: + +```text +locales/ +├─ built-in/ +│ ├─ zh-CN.json +│ └─ en-US.json +└─ downloaded/ + ├─ ja-JP/ + ├─ zh-TW/ + └─ ko-KR/ +``` + +Core 默认只内置少量基础语言: + +- `zh-CN` +- `en-US` + +其他语言采用按需下载。JSON 语言包通常不大;这样做的主要价值是避免主安装包随几十种语言和社区翻译一起膨胀,并允许社区翻译独立更新。 + +语言包至少包含: + +- locale 标识。 +- 语言显示名。 +- 版本号。 +- 兼容的 App/i18n API 版本。 +- 翻译文件。 +- 作者和来源。 +- 完整性哈希。 +- 可选字体或排版提示,但默认不打包字体文件。 + +语言包示例: + +```json +{ + "manifestVersion": 1, + "locale": "ja-JP", + "name": "日本語", + "version": "1.0.0", + "appVersion": ">=0.10.0", + "i18nVersion": "1", + "author": "Example", + "entry": "messages.json" +} +``` + +语言包安装流程: + +```text +语言设置 + ↓ +浏览可用语言 + ↓ +下载语言包 + ↓ +校验版本和 SHA-256 + ↓ +本地安装 + ↓ +即时切换 +``` + +要求: + +- 用户可删除非内置语言包。 +- 语言包更新前显示版本变化。 +- 下载失败不能影响当前语言。 +- 缺失翻译键自动回退到默认语言。 +- 语言包不能执行 JavaScript、Node.js 或任意代码。 +- 语言包不得拥有网络、文件系统或插件权限。 +- 翻译包应是纯数据格式,例如 JSON。 +- 不允许语言包覆盖安全提示、权限名称等关键语义而造成误导。 + +建议回退链: + +```text +ja-JP + ↓ missing key +en-US + ↓ missing key +internal fallback key +``` + +未来可增加: + +- 社区语言包索引。 +- 翻译完成度。 +- 缺失键检测。 +- CI 校验。 +- 社区 PR 翻译。 +- RTL 语言支持。 + +### 9.2 GUI 个性化 + +界面个性化优先采用声明式配置,而不是允许用户直接修改 React、DOM 或任意注入 JavaScript。 + +第一阶段支持: + +- Accent Color。 +- Light / Dark / System。 +- Theme Preset。 +- 字体大小。 +- UI Density。 +- 圆角大小。 +- 动画强度。 +- 卡片间距。 +- Repository 卡片显示字段。 +- Sidebar 宽度和折叠状态。 +- 列表 / Grid 默认布局。 +- 首页模块显示和排序。 + +统一主题 Token 示例: + +```ts +interface ThemeTokens { + colorAccent: string; + radius: 'none' | 'small' | 'medium' | 'large'; + density: 'compact' | 'comfortable' | 'spacious'; + fontScale: number; + animation: 'reduced' | 'normal' | 'enhanced'; +} +``` + +不要让主题直接依赖组件内部 className 或 DOM selector。优先使用语义化变量: + +```css +--color-background +--color-surface +--color-primary +--color-muted +--radius-card +--spacing-density +``` + +而不是: + +```css +.repository-card > div:nth-child(2) { ... } +``` + +这样组件重构时主题不容易失效。 + +### 9.3 布局个性化 + +第二阶段可以提供有限的 Dashboard Layout Configuration: + +```text +Home +├─ Trending +├─ Recently Viewed +├─ Update Available +├─ Starred Repositories +├─ My Apps +└─ Plugin Widgets +``` + +用户可以: + +- 显示 / 隐藏模块。 +- 调整模块顺序。 +- 调整部分卡片尺寸。 +- 选择默认首页。 +- 保存多个布局预设。 + +布局配置只保存: + +```json +{ + "home": [ + "updates", + "trending", + "recently-viewed" + ] +} +``` + +不保存任意 HTML、JS 或 React Component。 + +### 9.4 Community Themes + +未来可以支持独立主题包,但主题包只允许包含: + +- `manifest.json`。 +- CSS Variables。 +- Token 配置。 +- 可选静态图片资源。 + +例如: + +```text +theme/ +├─ manifest.json +├─ theme.css +└─ assets/ +``` + +主题包不得: + +- 执行 JavaScript。 +- 访问 Electron IPC。 +- 读取 Token。 +- 读取 Repository 数据。 +- 发起网络请求。 +- 修改插件权限。 + +主题包与功能插件分离: + +```text +Language Pack +→ 只负责文本 + +Theme Pack +→ 只负责视觉 Token + +Plugin +→ 负责功能扩展 +``` + +三者不能互相继承权限。 + +### 9.5 前端高级定制边界 + +近期不支持: + +- 任意 React Component 注入。 +- 任意 DOM 修改脚本。 +- 用户 JavaScript。 +- Theme 包访问 Store。 +- Theme 包访问 Node.js。 +- Theme 包执行网络请求。 + +如果未来确实需要高级 UI 扩展,继续通过现有 sandboxed Plugin Page 提供,而不是扩大 Theme 权限。 + +基本原则: + +> Theme changes appearance, Plugin changes behavior. + +## 10. 插件生态与商城 + +### 10.1 近期插件生态 + +先完善现有 Plugin API v1: + +- 稳定 Manifest、生命周期、权限和 Host Capability。 +- 保持同一 major API 内向后兼容。 +- 提供开发文档、类型、示例和调试工具。 +- 收集真实插件开发中的缺口,再增加扩展点。 +- 不允许插件直接访问 Token、Zustand、原始 IPC、Shell 或任意文件系统。 + +### 10.2 轻量商城起点 + +第一版商城参考“静态索引 + GitHub Release”模式,不立即建设复杂后台: + +```text +插件作者仓库和 GitHub Release + → 向插件索引仓库提交 PR + → CI 自动检查 + → 维护者人工 Review + → 合并至 community-plugins.json + → 客户端展示并安装固定版本 +``` + +建议索引: + +```text +community-plugins.json +removed-plugins.json +schemas/manifest.schema.json +``` + +每个插件版本至少记录: + +- Plugin ID 和版本。 +- API 兼容范围。 +- 源码和 Release URL。 +- 包 SHA-256。 +- 权限、网络目标和数据用途。 +- 审核状态、审核时间和对应 Commit。 +- 撤销状态和建议安全版本。 + +### 10.3 自动扫描 + +提交和每次更新至少检查: + +- Manifest、API 版本和插件 ID。 +- 包大小、文件数量、路径穿越和符号链接。 +- 依赖锁文件和已知高危漏洞。 +- Token、Key、私钥和其他秘密信息。 +- `eval`、动态代码、Shell、进程执行和安装脚本。 +- 未声明网络目标、遥测和自更新逻辑。 +- 混淆代码及源码与构建产物的对应关系。 +- 插件页面 CSP、远程资源和消息 Bridge。 + +自动扫描只负责发现风险,不替代人工审核和运行时权限边界。 + +### 10.4 人工审核 + +审核者确认: + +- 描述与行为一致。 +- 每项权限都有必要理由。 +- 联网域名和数据用途明确。 +- 不上传私有 Repository、浏览历史或其他非必要数据。 +- 停用和卸载后没有遗留后台任务。 +- UI 不冒充宿主或系统提示。 +- 许可证、名称、图标和源码来源合规。 + +每个新版本重新检查;权限增加、域名扩大和数据用途改变必须重新人工审核。 + +### 10.5 签名、撤销和更新 + +- 同一版本号只能对应一个不可变哈希。 +- 客户端安装前验证索引签名和插件包 SHA-256。 +- 签名私钥不能存放在源码仓库或普通日志中。 +- 维护签名撤销列表,记录原因、公告和建议回退版本。 +- 被撤销版本停止新安装,并提醒已安装用户。 +- 更新前展示版本、发布者、Changelog 和权限变化。 +- 新增权限时暂停更新并重新征得用户同意。 +- 第一阶段只提供更新提醒和手动确认;自动更新放在签名、撤销和回滚稳定之后。 + +### 10.6 商城治理边界 + +商城托管、审核责任、签名密钥、紧急撤销和申诉流程必须先得到维护者确认。商城审核通过不意味着插件获得更高运行权限。 + +## 11. Android 路线 + +Android 不是桌面 UI 的简单移植,应在共享领域模型之上实现独立平台适配器。 + +可共享: + +- Repository、Release 和 Asset 数据。 +- 软件资产识别和版本比较。 +- My Apps 关联记录。 +- Discovery 和搜索逻辑。 +- Plugin Manifest 的只读展示;是否支持执行需另行设计。 + +Android 专属: + +- PackageManager 软件清单。 +- APK 安装确认流程。 +- 系统权限页跳转。 +- 后台任务和通知限制。 +- 存储访问框架和下载目录。 +- Android 生命周期和网络策略。 + +普通应用不得声称可以随意替其他应用授予权限、静默安装、静默卸载或降级。Shizuku、ADB、Root 和设备管理能力若未来支持,必须作为明确的高级模式单独设计,不作为默认路径。 + +建议先验证 Web/响应式浏览体验,再提交 Android 客户端架构 Proposal;不要在桌面路线尚未稳定时复制全部状态和插件运行时。 + +## 12. 推荐实施阶段 + +### 阶段 A:稳定基础平台 + +- 完成现有插件平台合并后的回归和文档。 +- 冻结 Plugin API v1 的核心契约。 +- 确认 Repository、Release 和 Asset 的共享数据边界。 + +验收:现有功能无回归,示例插件可稳定安装、启停和卸载。 + +### 阶段 B:Repository Health Facts + +- 获取和规范化客观 Health 数据。 +- Repository 详情页展示。 +- 增加基础筛选和排序。 +- 向插件暴露稳定只读数据。 + +验收:不使用主观总分,也不会把成熟但低频更新的项目自动判定为不健康。 + +### 阶段 C:Installable Asset Detection + +- 建立统一资产模型。 +- 支持 Windows、macOS、Linux 和 Android 常见格式识别。 +- 复用 Smart Release 的平台与架构规则。 +- 提供可解释的匹配原因和候选项。 + +验收:兼容资产可稳定识别,冲突平台和架构不会被推荐。 + +### 阶段 D:My Apps MVP + +- 手动关联软件和 Repository。 +- 手动记录和修正安装版本。 +- 比较最新 Release。 +- 用户确认后下载更新。 + +验收:完成“关联 → 检查 → 查看 Changelog → 下载”的闭环,不执行安装程序。 + +### 阶段 E:Discovery、Trending 与批量导入 + +- Trending Snapshot 同步和榜单历史。 +- Trending Repository 使用统一 Repository 详情。 +- 平台感知的软件发现。 +- Recently Viewed 和 Hide Seen。 +- Batch Repository Intake:文本 / JSON URL 提取、去重和 Preview。 +- 将现有 Repository Search History 和 Suggestions 扩展为全局历史。 +- Omni Search MVP:统一搜索和导航现有本地数据。 +- Developer Profile。 + +验收: + +- Trending 榜单同步不会复制 Repository 主数据。 +- 能从混合文本和 JSON 中正确识别、归一化和去重 Repository。 +- Import Preview 不会因单个 Repository 获取失败而导致整批失败。 +- 所有历史记录可关闭和删除。 +- 跨平台结果不会被错误隐藏。 +- 关键词全局搜索可以离线工作。 + +### 阶段 F:历史版本和系统集成 + +- Release Version Picker。 +- 旧版资产下载和风险提示。 +- README、Release Notes 和备注的本地全文索引。 +- 可选语义搜索结果。 +- Deep Link。 +- 可选 Clipboard GitHub URL Detection。 +- 可配置的后台更新检查和通知。 + +验收:所有外部输入经过校验,下载和副作用操作需要用户确认。 + +### 阶段 G:Localization 与个性化 + +- 重构现有中英文逻辑为统一 i18n key。 +- 保留 `zh-CN` / `en-US` 内置语言。 +- 支持本地安装和删除语言包。 +- 提供缺失翻译回退和版本兼容检查。 +- 建立 Theme Token 系统。 +- 支持颜色、密度、字体缩放和布局预设。 +- 支持安全的社区 Theme Pack。 + +验收: + +- 缺失或损坏语言包不会导致应用无法启动。 +- 删除语言包后自动回退到内置语言。 +- 主题不需要依赖 DOM selector。 +- Theme 和 Language Pack 均不能执行代码或访问敏感数据。 + +### 阶段 H:插件公共生态 + +- 静态插件索引。 +- PR 提交和自动扫描。 +- 人工审核清单。 +- 哈希、签名、撤销、回滚和更新提醒。 + +验收:商城安装只接受固定版本和匹配哈希;权限增加时重新确认。 + +### 阶段 I:Android 验证 + +- 共享数据层和平台适配器设计。 +- Repository 浏览、Release 和 My Apps 只读体验。 +- APK 下载与系统安装确认。 +- 系统权限页跳转和更新通知。 + +验收:不依赖 Root/Shizuku 也能完成基础闭环,高权限模式不进入默认实现。 + +## 13. 建议 PR 顺序 + +每项尽量保持为独立 PR,每一次PR完成后停下,等待后续指令: + +1. `feat: add repository health facts` +2. `feat: detect installable release assets` +3. `feat: refactor bilingual UI into scalable i18n` +4. `feat: add downloadable language packs` +5. `feat: add theme token customization` +6. `feat: add configurable home layout` +7. `feat: add safe community theme packs` +8. `feat: add batch repository URL extraction` +9. `feat: add batch repository import preview` +10. `feat: add trending snapshots` +11. `feat: add trending history and filters` +12. `feat: add recently viewed repositories` +13. `feat: add global omni search` +14. `feat: expand repository search history to global history` +15. `feat: add manual repository-app linking` +16. `feat: detect updates for linked applications` +17. `feat: add platform-aware software discovery` +18. `feat: add release version picker` +19. `feat: add indexed content search` +20. `feat: add repository deep links` +21. `feat: detect GitHub URLs from clipboard` +22. `docs: propose community plugin registry` +23. `feat: add signed community plugin index` +Android 应先提交独立设计 Proposal,不和上述桌面 PR 混合。 + +## 14. 暂不支持 + +- 自动或静默执行 EXE、MSI、PKG、DEB、RPM、AppImage 或 APK。 +- 通用自动卸载和自动降级。 +- 插件调用 Shell、任意 Node、任意文件系统或任意网络。 +- 后台持续读取完整剪贴板。 +- 无用户确认地扫描、上传或关联全部本机软件。 +- 用单一规则为所有 Repository 生成官方 Health 总分。 +- 未经签名和撤销体系保护的插件自动更新。 +- 默认依赖 Root、ADB、Shizuku 或系统管理员权限。 + +## 15. 需要维护者确认 + +1. Repository Health Facts 是否符合产品核心定位? +2. 项目是否接受 My Apps 的“手动关联优先”路线? +3. 第一版是否明确只下载更新、不执行安装? +4. 浏览历史、搜索历史和软件关联数据应如何持久化与同步? +5. 是否接受静态 GitHub 插件索引作为商城起点? +6. 谁负责商城托管、审核、签名密钥和紧急撤销? +7. Android 是响应式/Web 包装、独立客户端,还是暂不进入近期路线? +8. 哪些平台适配器和包格式应作为首批正式支持范围? + +## 16. 成功标准 + +近期成功不以功能数量衡量,而以以下闭环衡量: + +1. 用户能够理解一个 Repository 当前是否仍被维护。 +2. 用户能够判断它是否包含适合当前设备的软件。 +3. 用户能够把已安装软件关联到正确的 Repository。 +4. 用户能够知道是否存在新版本并安全下载。 +5. 插件能够在不接触凭据和宿主内部状态的前提下扩展分析能力。 +6. 每项历史、扫描、联网和通知能力都可解释、可关闭、可删除。 +7. 用户能够一次粘贴包含多个 GitHub 链接的文本或 JSON,并统一识别、去重、查看和处理这些 Repository。 +8. 用户能够查看 Trending Repository 的完整详情、历史榜单状态和与本地收藏、Release、My Apps 的关联。 + +完成这些闭环后,再根据真实用户反馈决定是否扩大到自动安装、完整商城后台或更高权限的平台集成。 diff --git a/docs/reports/2026-09-20-main-vs-plugin-system-v0-9-merge-analysis.md b/docs/reports/2026-09-20-main-vs-plugin-system-v0-9-merge-analysis.md new file mode 100644 index 000000000..dc122c480 --- /dev/null +++ b/docs/reports/2026-09-20-main-vs-plugin-system-v0-9-merge-analysis.md @@ -0,0 +1,175 @@ +# main 与 plugin-system-v0-9 合并分析报告 + +- 日期:2026-09-20 +- 结论:**不能直接用普通 merge**;`main` 内容是本分支的真子集,推荐用 `-s ours` 记录合并后把 `main` 前进到本分支内容 +- 状态:**本报告只是分析。没有改动 main、没有创建任何分支或提交、没有推送任何远程。** + +## 1. 结论摘要 + +| 事项 | 结论 | +|---|---| +| 历史关系 | `git merge-base plugin-system-v0-9 upstream/main` **为空** —— 两条线没有共同祖先 | +| 提交数 | main 独有 1084,本分支独有 1132 | +| 内容关系 | main = 本分支 **− 21252 行 / + 647 行**,且 main **没有任何本分支缺少的文件** | +| main 真正的独有内容 | 只有 **2 个文件、共 84 净行**,且都是 ADR 0001 重构**之前**的旧形态 | +| 普通 merge | 222 处冲突(几乎每个同名文件)→ 不可用 | +| `-X theirs` | 冲突为 0,但结果会**新旧路径重复并存**(多出 1032 行、6 个文件)→ **不可用** | +| 推荐做法 | 在本分支上 `git merge -s ours upstream/main` 生成合并提交,再把 main 前进到它 | +| 备选 | force-push main 指向本分支(历史线性,但 main 的 1084 个提交会消失) | + +## 2. 取证命令与原始数据 + +```text +git merge-base plugin-system-v0-9 upstream/main -> (空) +git rev-list --left-right --count HEAD...upstream/main -> 1132 1084 +git rev-list --count HEAD..upstream/main -> 1084 (main 有我没有) +git rev-list --count upstream/main..HEAD -> 1132 (我有 main 没有) +git merge-base --is-ancestor dda7305 HEAD -> 1 (不是祖先) +git merge-base --is-ancestor 8a915a0 upstream/main -> 1 (不是祖先) +git diff --stat HEAD upstream/main -> 184 files, +647 / -21252 +git diff --name-status HEAD upstream/main -> M 74 / D 104 / R 6 / A 0 +``` + +`A 0` 是关键:**main 里不存在任何本分支没有的文件**。 + +## 3. main 独有的内容到底是什麼 + +逐文件净增行数里只有两项为正: + +| 净增 | 文件 | +|---|---| +| +43 | `src/features/releases/hooks/useReleaseTimelineActions.ts` | +| +41 | `src/features/releases/hooks/useReleaseTimelineActions.test.tsx`(由 `useWatchedSourcesSync.test.tsx` 改名而来) | + +那 +43 行是 `syncWatchedSources` 的**内联实现**,注释里自己写着「原 ReleaseSourceSettingsModal → +WatchCustomReleaseSyncPanel.handleSync」。本分支已经把这段逻辑抽成 +`src/features/releases/hooks/useWatchedSourcesSync.ts`(连带单测),即 PR #352 的 ADR 0001 归位重构。 + +6 个 rename 对照进一步印证 main 是重构前布局: + +| main 的路径 | 本分支的路径 | +|---|---| +| `src/hooks/useAuthSessionGeneration.ts` | `src/features/lifecycle/useAuthSessionGeneration.ts` | +| `src/features/lifecycle/useBackendLifecycle.ts` | `src/features/lifecycle/hooks/useBackendLifecycle.ts` | +| `src/features/lifecycle/useBackendLifecycle.test.tsx` | `src/features/lifecycle/hooks/useBackendLifecycle.test.tsx` | +| `src/features/releases/hooks/useReleaseTimelineActions.test.tsx` | `src/features/releases/hooks/useWatchedSourcesSync.test.tsx` | +| `src/features/repository-chat/repositories/sessionRepository.ts` | `src/services/repositoryChatStorage.ts` | +| `src/features/repository-chat/repositories/sessionRepository.test.ts` | `src/services/repositoryChatStorage.test.ts` | + +main 的第二个独有提交 `8e89787 feat: discover local plugin manifests v0.9.0` 只做了插件系统的**第一步**: +新增 `electron/plugins/pluginManager.js` 的 `list()` 与 `plugins:list` IPC(`main.js` +10 行、 +`preload.js` +3 行)。本分支已有完整实现: +`electron/main.js:881 ipcMain.handle('plugins:list', ...)`、`electron/preload.js:31`。 +它的 `manifestSchema.js` 也是更简版本(用 `Set` 常量表验证字段/权限),本分支的版本更完整。 + +## 4. main 缺少的东西(104 个只在本分支的文件) + +| 目录 | 文件数 | 内容 | +|---|---|---| +| `electron/plugins` | 25 | 插件平台全部实现与测试(capabilityRouter / pluginCatalog / pluginPage / pluginProtocol / pluginRuntime / pluginStorage / pluginWorker / releaseDownload / webSearch …) | +| `src/components` | 14 | Telegram/XTweet/周刊弹窗、PluginPageViewer、ReleasePluginRecommendations、RepositoryHealthPanel、InstallableAssetRecommendation 等 | +| `src/plugins` | 11 | 渲染侧插件客户端、registry、hooks、快照桥 | +| `src/utils` | 11 | 含本两阶段的 `repositoryHealth.ts` / `installableAssets.ts` / `deviceTarget.ts` / `formatBytes.ts`,以及 telegram/xTweet 工具 | +| `examples/plugins` | 11 | 三个示例插件(含 Smart Release Recommender) | +| `src/services` | 10 | telegramService / xTweetService / xTweetStorage / telegramStorage / 抓取夹具 | +| `src/features` 等 | 5 | 发现频道 probe、生命周期 hook | +| `src/store` | 3 | xTweetAuth 持久化、拖拽 store | +| `server/src` | 3 | `routes/telegram.ts`、`routes/xtweet.ts` 等 | +| 其他 | 9 | `electron/repoHealth.js(.test)`、`electron/xAuthStorage.js(.test)`、docs(插件设计、路线图、v1 开发文档、两份阶段日志) | + +概括:main 缺 **PR #352–#356**(分层重构、X 频道、拖拽取消分类、Telegram 频道 + X 鉴权持久化、 +v0.8.1)、整套插件平台、以及本次的 Health 事实与可安装资产识别。 + +## 5. 版本与发布元数据 + +| | main | plugin-system-v0-9 | +|---|---|---| +| `package.json` version | `0.9.0` | `0.10.0` | +| `versions/version-info.xml` 最高版本 | **`0.8.0`** | **`0.10.0`** | +| 0.9.0 条目 | **不存在** | 存在(Health 事实 + 补记的插件平台) | +| `test:electron:mcp` | `mcpLocalServer + desktopPrefs` | 追加 `xAuthStorage` + `repoHealth` | + +好消息:main 的 `version-info.xml` 只到 0.8.0,**不存在重复的 0.9.0 条目**, +合并后不会出现两个 0.9.0。main 的 `package.json` 是 0.9.0 但没有 0.9.0 changelog 条目—— +这是它自己那一步的不一致,合并后自然被 0.9.0/0.10.0 两条完整条目覆盖。 + +## 6. 三种合并方式实测 + +### 6.1 普通 merge —— 不可用 + +```text +git merge-tree --write-tree --allow-unrelated-histories upstream/main plugin-system-v0-9 +-> exit=1,222 行冲突信息 +``` + +无共同祖先时每个同名文件都是 add/add 冲突。 + +### 6.2 `-X theirs` —— 干净但有重复模块,不可用 + +```text +git merge-tree --write-tree --allow-unrelated-histories -X theirs upstream/main plugin-system-v0-9 +-> exit=0,结果树 36ab7ab335a6064eb135c7f4e5d531dc16891d05 + 本分支树 c09ee616bff66123457950dc6e29937466cd9365 +-> 不相等:合并结果多出 1032 行 +``` + +多出的是第 3 节那 6 个文件——它们在本分支被**移动**过,而 git 在无共同祖先时无法识别跨分支 rename, +于是新旧两个路径会**同时存在**(例如 `src/services/repositoryChatStorage.ts` 与 +`src/features/repository-chat/repositories/sessionRepository.ts` 并存)。这会造成重复模块、 +两套实现互不引用,构建与测试都会失真。**必须排除这个方案。** + +### 6.3 `-s ours` —— 推荐 + +```text +# 在本分支上把 main 作为第二父线并入;ours 策略不读对方树,只记录「我们的历史包含对方的历史」 +git checkout -b tmp-merge-main plugin-system-v0-9 +git merge -s ours --allow-unrelated-histories --no-edit upstream/main +# 验证:必须为空输出 +git diff --stat plugin-system-v0-9 tmp-merge-main +# 让 main 前进到合并提交(main 未签出,可直接 -f;或 git checkout main && git merge --ff-only tmp-merge-main) +git branch -f main tmp-merge-main +git push upstream main +git push origin main +``` + +结果:`main` 的树 **逐字节等于**本分支(含 v0.10.0 的全部内容),main 的 1084 个提交作为第二父线 +保留在历史中,不改写历史。代价是 main 那 2 个旧文件被本分支的重构版取代(见 §7)。 + +`merge-tree` 无法预演这一条(它只跑 ort 策略,不重现 `ours` 策略),所以验证放在创建合并提交 +**之后、推送之前**:`git diff --stat plugin-system-v0-9 tmp-merge-main` 必须为空。 +本地建分支不产生任何远程影响,你确认后我再执行并推送。 + +### 6.4 force-push / reset —— 备选 + +```text +git push upstream plugin-system-v0-9:main --force-with-lease +``` + +历史线性干净、没有空的合并提交,但 main 现有 1084 个提交会从 main 上消失(后续 gc 回收)。 +只有在确认没有任何人/任何开放 PR 基于当前 main 时才考虑。 + +## 7. 风险与取舍 + +1. **main 的 2 个旧文件会被取代**:行为等价(本分支是同一逻辑的重构版 + 单测),但文件形态不同。 + 若 `-s ours`,这两个路径在 main 的新树里不存在。 +2. **历史里会有重复叙事**:main 独有提交的标题(如 `feat: add backend login recovery flow`、 + `Merge pull request #329` 等)在本分支里有内容相同但 SHA 不同的对应提交。合并后 main 的历史里 + 两套 SHA 都在,`git log` 看起来会有「同一件事说了两遍」。这是两条独立历史合并的必然结果。 +3. **没有内容损失**:内容维度上 main ⊆ 本分支(除 §3 那 2 个旧文件),`-s ours` 后 + `git diff plugin-system-v0-9 main` 为空即可证明。 +4. **构建/测试影响**:合并后 main 的 `package.json` 是本分支版本,`test:electron:mcp` + 包含 `repoHealth.test.js`;`node_modules` 无需重装(依赖未变)。 +5. **推送目标**:按你的选择,批准后同时推 `upstream`(Khk-NL/GithubStarsManager)与 + `origin`(Khk-NL/GithubStarsManager_PluginSystem)的 main。 + +## 8. 建议的下一步 + +推荐 §6.3。若你确认,我会: + +1. 建临时分支 + `-s ours` 合并提交(不动 main、不推送); +2. 用 `git diff --stat plugin-system-v0-9 tmp-merge-main` 证明零内容损失; +3. 在合并结果上跑 `check:boundaries` / `typecheck` / `lint` / `vitest`, + 确认与阶段 2 的基线一致; +4. 通过后再 `git branch -f main` 并推送两个远程,最后删除临时分支。 + +若你更想要线性历史,就走 §6.4,我会先做一次 `--dry-run` 并把影响范围(会消失的 1084 个提交)再列给你。 diff --git a/electron/mcpDiscovery.js b/electron/mcpDiscovery.js index 4f05f2565..ca9461f98 100644 --- a/electron/mcpDiscovery.js +++ b/electron/mcpDiscovery.js @@ -1,3 +1,9 @@ +const { + deriveRepositoryHealthFacts, + hasRecentActivity, + isArchivedRepository, +} = require('./repoHealth'); + const NO_LICENSE_SENTINEL = '__NO_LICENSE__'; const NOASSERTION_KEYS = new Set(['', 'noassertion', 'other', 'none', 'no-license']); @@ -75,6 +81,17 @@ function matchesRepoFilters(repo, filters = {}) { } if (filters.minStars !== undefined && (repo.stargazers_count || 0) < filters.minStars) return false; if (filters.maxStars !== undefined && (repo.stargazers_count || 0) > filters.maxStars) return false; + // Repository Health 事实过滤:与 src/utils/repoSearch.ts 同一套语义,保证 UI 与 MCP 结果一致。 + if (filters.healthArchived !== undefined && isArchivedRepository(repo) !== filters.healthArchived) { + return false; + } + if (filters.healthRecentActivity !== undefined && hasRecentActivity(repo) !== filters.healthRecentActivity) { + return false; + } + if (filters.healthHasLicense !== undefined) { + const hasLicense = normalizeLicense(repo.license) !== NO_LICENSE_SENTINEL; + if (hasLicense !== filters.healthHasLicense) return false; + } if (filters.category && filters.category !== 'all' && repo.custom_category !== filters.category) { return false; } @@ -91,6 +108,8 @@ function sortableValue(repo, sortBy) { return String(repo.name || '').toLowerCase(); case 'starred': return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; + case 'created': + return repo.created_at ? new Date(repo.created_at).getTime() : 0; default: return new Date(repo.pushed_at || repo.updated_at || 0).getTime(); } @@ -212,12 +231,17 @@ function buildBatchLookupResult(inputs, resolve) { }; } -function buildRepoEvidence(repo, latestRelease) { +function buildRepoEvidence(repo, latestRelease, releases) { const analysisStatus = repo.analyzed_at ? repo.analysis_failed ? 'failed' : 'analyzed' : 'not_analyzed'; + // Repository Health 客观事实:与 UI / 插件同源(见 repoHealth.js)。 + // 传 releases 时补全 Release 相关事实;不传时这些字段为 null(未知)而不是 0。 + const health = deriveRepositoryHealthFacts(repo, releases); + // archived 只在记录中确实存在该布尔值时才断言,否则保持 null 并声明限制。 + const hasArchivedFlag = typeof repo.archived === 'boolean'; return { repository: projectRepo(repo, 2000), evidence: { @@ -235,8 +259,9 @@ function buildRepoEvidence(repo, latestRelease) { subscribed_to_releases: !!repo.subscribed_to_releases, analysis_status: analysisStatus, analyzed_at: repo.analyzed_at ?? null, - archived: null, + archived: hasArchivedFlag ? repo.archived : null, }, + health, latest_release: latestRelease || null, sources: { repository: 'repositories', @@ -256,7 +281,10 @@ function buildRepoEvidence(repo, latestRelease) { ], }, limitations: [ - 'archived is not stored locally', + // 只有本地确实没有该事实时才声明这项限制;有了就不要再谎称拿不到。 + ...(hasArchivedFlag ? [] : ['archived is not stored locally']), + 'health facts cover the stored repository record and locally cached releases only', + 'contributors, closed issues, security policy, CI and README presence require extra GitHub requests and are not stored locally', 'release evidence is limited to locally cached releases', ], }, diff --git a/electron/mcpLocalServer.js b/electron/mcpLocalServer.js index 723102e60..12dd34ec9 100644 --- a/electron/mcpLocalServer.js +++ b/electron/mcpLocalServer.js @@ -295,7 +295,19 @@ function getMcpToolDefinitions(vectorAvailable) { maxStars: { type: 'number' }, isAnalyzed: { type: 'boolean' }, isSubscribed: { type: 'boolean' }, - sortBy: { type: 'string', enum: ['stars', 'updated', 'name', 'starred'] }, + healthArchived: { + type: 'boolean', + description: 'Repository Health fact filter: true = only archived repositories, false = only non-archived', + }, + healthRecentActivity: { + type: 'boolean', + description: 'Repository Health fact filter: true = pushed within the last 12 months', + }, + healthHasLicense: { + type: 'boolean', + description: 'Repository Health fact filter: true = has a declared SPDX license, false = no declared license', + }, + sortBy: { type: 'string', enum: ['stars', 'updated', 'name', 'starred', 'created'] }, sortOrder: { type: 'string', enum: ['asc', 'desc'] }, limit: { type: 'number' }, offset: { type: 'number' }, @@ -352,7 +364,19 @@ function getMcpToolDefinitions(vectorAvailable) { category: { type: 'string' }, limit: { type: 'number' }, offset: { type: 'number' }, - sortBy: { type: 'string', enum: ['stars', 'updated', 'name', 'starred'] }, + healthArchived: { + type: 'boolean', + description: 'Repository Health fact filter: true = only archived repositories, false = only non-archived', + }, + healthRecentActivity: { + type: 'boolean', + description: 'Repository Health fact filter: true = pushed within the last 12 months', + }, + healthHasLicense: { + type: 'boolean', + description: 'Repository Health fact filter: true = has a declared SPDX license, false = no declared license', + }, + sortBy: { type: 'string', enum: ['stars', 'updated', 'name', 'starred', 'created'] }, sortOrder: { type: 'string', enum: ['asc', 'desc'] }, }, required: ['category'], @@ -488,7 +512,7 @@ async function callTool(name, args, snapshot) { const key = String(args?.idOrFullName || '').trim(); const repo = findSnapshotRepository(repos, key); if (!repo) return text({ error: 'not_found', idOrFullName: key }); - return text(buildRepoEvidence(repo, getLatestCachedRelease(releases, repo.id))); + return text(buildRepoEvidence(repo, getLatestCachedRelease(releases, repo.id), releases)); } case 'gsm_list_categories': return text({ categories }); diff --git a/electron/mcpLocalServer.test.js b/electron/mcpLocalServer.test.js index e3c3d11fb..d645c81c4 100644 --- a/electron/mcpLocalServer.test.js +++ b/electron/mcpLocalServer.test.js @@ -150,6 +150,9 @@ test('Electron lists the same ten-tool conditional surface', () => { 'maxStars', 'isAnalyzed', 'isSubscribed', + 'healthArchived', + 'healthRecentActivity', + 'healthHasLicense', 'sortBy', 'sortOrder', 'limit', diff --git a/electron/plugins/pluginProtocol.js b/electron/plugins/pluginProtocol.js index 108f28b69..6414919c5 100644 --- a/electron/plugins/pluginProtocol.js +++ b/electron/plugins/pluginProtocol.js @@ -87,6 +87,18 @@ function sanitizeRepository(repository) { ? repository.topics.filter((topic) => typeof topic === 'string').slice(0, 100) : [], license: typeof repository.license === 'string' ? repository.license : null, + // Repository Health 客观事实(见 docs/plans/2026-09-17-product-roadmap.md §4)。 + // 全部来自公开仓库元数据,属于既有 `repositories:read` 权限范围,不新增能力; + // 未知一律为 null,插件据此可以区分「未归档」与「本地没有这个事实」。 + archived: typeof repository.archived === 'boolean' ? repository.archived : null, + disabled: typeof repository.disabled === 'boolean' ? repository.disabled : null, + fork: typeof repository.fork === 'boolean' ? repository.fork : null, + is_template: typeof repository.is_template === 'boolean' ? repository.is_template : null, + open_issues_count: Number.isFinite(repository.open_issues_count) + ? repository.open_issues_count + : null, + default_branch: typeof repository.default_branch === 'string' ? repository.default_branch : null, + has_fetched_releases: repository.has_fetched_releases === true, }; } diff --git a/electron/repoHealth.js b/electron/repoHealth.js new file mode 100644 index 000000000..e4f6083f1 --- /dev/null +++ b/electron/repoHealth.js @@ -0,0 +1,155 @@ +/** + * Repository Health facts (Electron / CommonJS build). + * + * 与 `src/utils/repositoryHealth.ts` 保持同一套算法:Core 的客观事实必须跨运行时一致, + * 否则 UI、MCP 与插件会给出互相矛盾的数字。 + * + * 为什么是重复实现而不是共享模块:Electron 侧是 CommonJS(electron/package.json 无 + * `"type": "module"`),而仓库根是 ESM,且 electron-builder 只打包 `dist/`、`electron/` + * 与 `node_modules/`,因此无法 import `src/` 树。这与既有 `mcpDiscovery.js` / + * `server/src/mcp/repoSearch.ts` 的三份镜像实现是同一约束;改这里时请同步 + * `server/src/mcp/repoHealth.ts` 与 `src/utils/repositoryHealth.ts`。 + * + * 边界与 TS 版一致:只输出客观事实与保守观测,不输出健康总分或「健康 / 不健康」结论。 + */ + +const MS_PER_DAY = 86400000; +const DAYS_PER_YEAR = 365.25; +/** 与 TS 版一致:超过一年无 push 只作为中性观测「No pushes in 12 months」。 */ +const NO_RECENT_ACTIVITY_DAYS = 365; +const MIN_FREQUENCY_WINDOW_DAYS = 30; +const PRERELEASE_TOKENS = new Set([ + 'alpha', 'beta', 'rc', 'pre', 'prerelease', 'preview', 'dev', 'devel', + 'next', 'canary', 'snapshot', 'nightly', 'insider', 'unstable', +]); + +function toTimestamp(value) { + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function toCount(value) { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; +} + +function round1(value) { + return Math.round(value * 10) / 10; +} + +/** 布尔事实的三态化:只有记录里确实存在布尔值时才给出 true/false,否则为 null(未知)。 */ +function toTriState(value) { + return typeof value === 'boolean' ? value : null; +} + +/** 预发布判定:先信任 GitHub 标记,再按 tag 词元兜底(避免 `presto` 里的 `pre` 误判)。 */ +function isPrereleaseRelease(release) { + if (release?.prerelease === true) return true; + const tag = String(release?.tag_name ?? '').toLowerCase(); + if (!tag) return false; + return tag + .split(/[^a-z0-9]+/) + .filter(Boolean) + .some((token) => PRERELEASE_TOKENS.has(token.replace(/\d+$/, ''))); +} + +/** 取某仓库的 Release,按发布时间降序(不可解析的条目丢弃)。 */ +function releasesForRepository(releases, repositoryId) { + return (Array.isArray(releases) ? releases : []) + .filter((release) => Number(release?.repository?.id ?? release?.repo_id) === Number(repositoryId)) + .filter((release) => toTimestamp(release.published_at) !== null) + .slice() + .sort((left, right) => toTimestamp(right.published_at) - toTimestamp(left.published_at)); +} + +/** + * 推导仓库健康事实(snake_case,便于直接作为 MCP JSON 证据输出)。 + * + * @param {object} repo 已存储的仓库记录。 + * @param {Array} [releases] 该仓库的 Release;不传表示调用方没有这项数据,Release 相关事实为 null。 + * @param {number} [now] 计算「距今多久」的基准时间,便于测试注入。 + */ +function deriveRepositoryHealthFacts(repo, releases, now = Date.now()) { + const provided = Array.isArray(releases); + const own = provided ? releasesForRepository(releases, repo?.id) : []; + const latest = own[0] ?? null; + const latestStable = own.find((release) => !isPrereleaseRelease(release)) ?? null; + const latestPrerelease = own.find((release) => isPrereleaseRelease(release)) ?? null; + + const createdTimestamp = toTimestamp(repo?.created_at); + const pushedTimestamp = toTimestamp(repo?.pushed_at || repo?.updated_at); + const ageDays = createdTimestamp === null + ? null + : Math.max(0, Math.floor((now - createdTimestamp) / MS_PER_DAY)); + const daysSinceLastPush = pushedTimestamp === null + ? null + : Math.max(0, Math.floor((now - pushedTimestamp) / MS_PER_DAY)); + const releasesPerYear = ageDays === null + ? (provided ? round1(own.length) : null) + : round1(own.length / (Math.max(ageDays, MIN_FREQUENCY_WINDOW_DAYS) / DAYS_PER_YEAR)); + + const facts = { + // GitHub 原生状态字段:三态。`null` = 该记录里根本没有这个事实(例如后端未存储), + // 不能当成 false —— 把「未知」说成「未归档」是伪造事实。 + archived: toTriState(repo?.archived), + disabled: toTriState(repo?.disabled), + fork: toTriState(repo?.fork), + is_template: toTriState(repo?.is_template), + + created_at: repo?.created_at ?? null, + pushed_at: repo?.pushed_at ?? null, + age_days: ageDays, + days_since_last_push: daysSinceLastPush, + + // Release 事实在未提供 releases 时为 null(未知),而不是 0(已知没有)。 + release_count: provided ? own.length : null, + has_releases: provided ? own.length > 0 : null, + releases_fetched: provided && repo?.has_fetched_releases === true, + latest_release_at: latest?.published_at ?? null, + latest_stable_version: latestStable?.tag_name ?? null, + latest_prerelease_version: latestPrerelease?.tag_name ?? null, + releases_per_year: releasesPerYear, + + stars: toCount(repo?.stargazers_count), + forks: repo?.forks_count !== undefined ? toCount(repo.forks_count) : toCount(repo?.forks), + open_issues_count: repo?.open_issues_count === undefined ? null : toCount(repo.open_issues_count), + default_branch: repo?.default_branch ?? null, + license: repo?.license ?? null, + }; + + return { ...facts, signals: deriveRepositoryHealthSignals(facts) }; +} + +/** Core 允许提供的保守观测,顺序固定(archived → disabled → no-releases → no-recent-activity)。 */ +function deriveRepositoryHealthSignals(facts) { + const signals = []; + if (facts.archived) signals.push('archived'); + if (facts.disabled) signals.push('disabled'); + if (facts.releases_fetched && facts.release_count === 0) signals.push('no-releases'); + if (facts.days_since_last_push !== null && facts.days_since_last_push >= NO_RECENT_ACTIVITY_DAYS) { + signals.push('no-recent-activity'); + } + return signals; +} + +function isArchivedRepository(repo) { + return repo?.archived === true; +} + +/** 与 TS 版一致:时间不可解析时返回 false(筛选语义下「未知」不算「近期活跃」)。 */ +function hasRecentActivity(repo, now = Date.now()) { + const pushed = toTimestamp(repo?.pushed_at) ?? toTimestamp(repo?.updated_at); + if (pushed === null) return false; + return now - pushed < NO_RECENT_ACTIVITY_DAYS * MS_PER_DAY; +} + +module.exports = { + NO_RECENT_ACTIVITY_DAYS, + deriveRepositoryHealthFacts, + deriveRepositoryHealthSignals, + hasRecentActivity, + isArchivedRepository, + isPrereleaseRelease, + releasesForRepository, +}; diff --git a/electron/repoHealth.test.js b/electron/repoHealth.test.js new file mode 100644 index 000000000..0578eb392 --- /dev/null +++ b/electron/repoHealth.test.js @@ -0,0 +1,125 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + NO_RECENT_ACTIVITY_DAYS, + deriveRepositoryHealthFacts, + hasRecentActivity, + isArchivedRepository, + isPrereleaseRelease, + releasesForRepository, +} = require('./repoHealth'); + +const NOW = Date.parse('2026-09-17T00:00:00.000Z'); + +function repo(overrides = {}) { + return { + id: 1, + full_name: 'acme/alpha', + created_at: '2020-09-17T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + pushed_at: '2026-09-01T00:00:00.000Z', + stargazers_count: 1500, + forks_count: 120, + license: 'MIT', + has_fetched_releases: true, + ...overrides, + }; +} + +function release(id, tag, publishedAt, extra = {}) { + return { + id, + repo_id: 1, + tag_name: tag, + published_at: publishedAt, + prerelease: false, + ...extra, + }; +} + +test('Electron health facts mirror the renderer algorithm', () => { + const facts = deriveRepositoryHealthFacts( + repo(), + [ + release(1, 'v1.0.0', '2025-01-01T00:00:00.000Z'), + release(2, 'v1.1.0', '2026-01-01T00:00:00.000Z'), + release(3, 'v2.0.0-rc1', '2026-06-01T00:00:00.000Z', { prerelease: true }), + ], + NOW + ); + + assert.equal(facts.release_count, 3); + assert.equal(facts.has_releases, true); + assert.equal(facts.latest_release_at, '2026-06-01T00:00:00.000Z'); + assert.equal(facts.latest_stable_version, 'v1.1.0'); + assert.equal(facts.latest_prerelease_version, 'v2.0.0-rc1'); + assert.equal(facts.age_days, 2191); + assert.equal(facts.days_since_last_push, 16); + assert.equal(facts.releases_per_year, 0.5); + assert.equal(facts.stars, 1500); +}); + +test('Electron health facts keep unknown values null instead of guessing', () => { + const withoutReleases = deriveRepositoryHealthFacts(repo(), undefined, NOW); + assert.equal(withoutReleases.release_count, null); + assert.equal(withoutReleases.has_releases, null); + assert.equal(withoutReleases.releases_fetched, false); + + // 后端 schema 不存储 GitHub 原生状态字段时必须保持 null(未知),不能断言「未归档」。 + const withoutStatus = deriveRepositoryHealthFacts(repo(), [], NOW); + assert.equal(withoutStatus.archived, null); + assert.equal(withoutStatus.disabled, null); + assert.equal(withoutStatus.fork, null); + assert.equal(withoutStatus.is_template, null); + assert.equal(withoutStatus.signals.includes('archived'), false); + + // 有了事实就照实上报 + const archived = deriveRepositoryHealthFacts(repo({ archived: true }), [], NOW); + assert.equal(archived.archived, true); + assert.equal(archived.signals.includes('archived'), true); +}); + +test('Electron health signals stay conservative and ordered', () => { + const facts = deriveRepositoryHealthFacts( + repo({ archived: true, disabled: true, pushed_at: '2020-01-01T00:00:00.000Z' }), + [], + NOW + ); + assert.deepEqual(facts.signals, ['archived', 'disabled', 'no-releases', 'no-recent-activity']); +}); + +test('Electron never invents release facts without release data', () => { + const facts = deriveRepositoryHealthFacts(repo({ has_fetched_releases: false }), undefined, NOW); + assert.equal(facts.signals.includes('no-releases'), false); +}); + +test('Electron prerelease detection matches tag tokens only', () => { + assert.equal(isPrereleaseRelease({ prerelease: true, tag_name: 'v1.0.0' }), true); + assert.equal(isPrereleaseRelease({ tag_name: 'v1.2.0-rc1' }), true); + assert.equal(isPrereleaseRelease({ tag_name: 'presto-1.0.0' }), false); + assert.equal(isPrereleaseRelease({ tag_name: 'v1.2.0' }), false); +}); + +test('Electron release lookup filters by repo and sorts newest first', () => { + const releases = [ + release(1, 'v1.0.0', '2025-01-01T00:00:00.000Z'), + release(2, 'v2.0.0', '2026-01-01T00:00:00.000Z'), + { ...release(3, 'v9.0.0', '2026-02-01T00:00:00.000Z'), repo_id: 99 }, + release(4, 'broken', 'not-a-date'), + ]; + assert.deepEqual( + releasesForRepository(releases, 1).map((item) => item.id), + [2, 1] + ); +}); + +test('Electron filter predicates match the renderer semantics', () => { + assert.equal(isArchivedRepository({}), false); + assert.equal(isArchivedRepository({ archived: true }), true); + assert.equal(hasRecentActivity({ pushed_at: '2026-09-10T00:00:00.000Z' }, NOW), true); + assert.equal(hasRecentActivity({ pushed_at: '2024-01-01T00:00:00.000Z' }, NOW), false); + // 时间不可解析时不算「近期活跃」,且阈值就是 12 个月 + assert.equal(hasRecentActivity({ pushed_at: '', updated_at: '' }, NOW), false); + assert.equal(typeof NO_RECENT_ACTIVITY_DAYS, 'number'); +}); diff --git a/package-lock.json b/package-lock.json index 36e1b36e7..eda8bea5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.8.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.8.1", + "version": "0.11.0", "dependencies": { "@ai-sdk/openai-compatible": "^3.0.37", "@fontsource-variable/dm-sans": "^5.3.0", diff --git a/package.json b/package.json index 6f4563b24..4d743cd03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "github-stars-manager", "private": true, - "version": "0.8.1", + "version": "0.11.0", "type": "module", "scripts": { "dev": "vite", @@ -15,7 +15,7 @@ "preview": "vite preview", "test": "vitest", "test:run": "vitest run && npm run test:electron:mcp && npm run test:electron:plugins && npm run test:update-version", - "test:electron:mcp": "node --test electron/mcpLocalServer.test.js electron/desktopPrefs.test.js electron/xAuthStorage.test.js", + "test:electron:mcp": "node --test electron/mcpLocalServer.test.js electron/desktopPrefs.test.js electron/xAuthStorage.test.js electron/repoHealth.test.js", "test:electron:plugins": "node --test electron/plugins/*.test.js", "test:update-version": "node --test scripts/update-version.test.cjs", "test:coverage": "vitest run --coverage", diff --git a/server/package-lock.json b/server/package-lock.json index 847c2f9f0..f1c4deafa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager-server", - "version": "0.8.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager-server", - "version": "0.8.1", + "version": "0.11.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "axios": "^1.18.0", diff --git a/server/package.json b/server/package.json index 32dcf4883..9242a3383 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "github-stars-manager-server", - "version": "0.8.1", + "version": "0.11.0", "private": true, "type": "module", "scripts": { diff --git a/server/src/mcp/evidence.ts b/server/src/mcp/evidence.ts index 6e2b76fa3..32856aa76 100644 --- a/server/src/mcp/evidence.ts +++ b/server/src/mcp/evidence.ts @@ -1,4 +1,9 @@ import { projectRepoForAgent, type McpRepository } from './repoSearch.js'; +import { + deriveRepositoryHealthFacts, + type HealthReleaseInput, + type RepositoryHealthFacts, +} from './repoHealth.js'; export interface McpReleaseEvidence { id: number; @@ -12,11 +17,14 @@ export interface McpReleaseEvidence { export function buildRepoEvidence( repo: McpRepository, - latestRelease: McpReleaseEvidence | null + latestRelease: McpReleaseEvidence | null, + /** 该仓库已缓存的 Release;不传表示调用方没有这项数据,Release 相关事实为 null(未知)。 */ + releases?: readonly HealthReleaseInput[], ): { repository: Record; evidence: { repository: Record; + health: RepositoryHealthFacts; latest_release: McpReleaseEvidence | null; sources: { repository: 'repositories'; latest_release: 'releases_cache' | null }; evidenceFreshness: { @@ -35,6 +43,10 @@ export function buildRepoEvidence( ? 'failed' : 'analyzed' : 'not_analyzed'; + // Repository Health 客观事实:与 UI / Electron MCP 同源(见 repoHealth.ts)。 + const health = deriveRepositoryHealthFacts(repo, releases); + // 后端 schema 不存储 GitHub 原生状态字段,此时保持 null 并声明限制,而不是断言「未归档」。 + const hasArchivedFlag = typeof repo.archived === 'boolean'; return { repository: projectRepoForAgent(repo, { summaryMaxChars: 2000 }), @@ -53,8 +65,9 @@ export function buildRepoEvidence( subscribed_to_releases: !!repo.subscribed_to_releases, analysis_status: analysisStatus, analyzed_at: repo.analyzed_at ?? null, - archived: null, + archived: hasArchivedFlag ? repo.archived : null, }, + health, latest_release: latestRelease, sources: { repository: 'repositories', @@ -74,7 +87,10 @@ export function buildRepoEvidence( ], }, limitations: [ - 'archived is not stored locally', + // 只有确实拿不到该事实时才声明限制;本地已有就不再谎称不可用。 + ...(hasArchivedFlag ? [] : ['archived is not stored locally']), + 'health facts cover the stored repository record and locally cached releases only', + 'contributors, closed issues, security policy, CI and README presence require extra GitHub requests and are not stored locally', 'release evidence is limited to locally cached releases', ], }, diff --git a/server/src/mcp/provider.ts b/server/src/mcp/provider.ts index eaac6d5d7..715abc6c3 100644 --- a/server/src/mcp/provider.ts +++ b/server/src/mcp/provider.ts @@ -18,6 +18,7 @@ import { VECTOR_CANDIDATE_LIMIT, } from './discovery.js'; import { buildRepoEvidence, type McpReleaseEvidence } from './evidence.js'; +import type { HealthReleaseInput } from './repoHealth.js'; function parseJsonColumn(value: unknown): unknown[] { if (typeof value !== 'string' || !value) return []; @@ -120,7 +121,38 @@ export function getLatestRelease(repoId: number): McpReleaseEvidence | null { export function getRepoEvidence(idOrFullName: string | number) { const repo = getRepository(idOrFullName); if (!repo) return { error: 'not_found' as const, idOrFullName: String(idOrFullName) }; - return buildRepoEvidence(repo, getLatestRelease(repo.id)); + return buildRepoEvidence(repo, getLatestRelease(repo.id), getRepositoryReleases(repo.id)); +} + +/** Soft cap on how many cached releases feed the health facts of a single repository. */ +const MAX_RELEASES_PER_REPO_EVIDENCE = 500; + +/** + * 单个仓库的轻量 Release 行(已按发布时间降序)。 + * Repository Health 需要 Release 数量、最新稳定版本与发布频率,这些都要看完整列表 + * 而不只是最新一条;因此这里单独取一份最小列集合并限制上限。 + * + * 注意 SQLite 把布尔存成 0/1,这里显式归一化为 boolean,否则 `prerelease === true` + * 永远为假,预发布会被误当成稳定版(与 getLatestRelease 的 `!!row.prerelease` 一致)。 + */ +export function getRepositoryReleases(repoId: number): HealthReleaseInput[] { + const db = getDb(); + const rows = db + .prepare( + `SELECT repo_id, tag_name, published_at, prerelease + FROM releases + WHERE repo_id = ? + ORDER BY (published_at IS NULL) ASC, published_at DESC, id DESC + LIMIT ?` + ) + .all(repoId, MAX_RELEASES_PER_REPO_EVIDENCE) as Array>; + + return rows.map((row) => ({ + repo_id: typeof row.repo_id === 'number' ? row.repo_id : Number(row.repo_id), + tag_name: typeof row.tag_name === 'string' ? row.tag_name : null, + published_at: typeof row.published_at === 'string' ? row.published_at : null, + prerelease: row.prerelease === 1 || row.prerelease === true, + })); } export function listCategories(): Array> { diff --git a/server/src/mcp/repoHealth.ts b/server/src/mcp/repoHealth.ts new file mode 100644 index 000000000..189d0becb --- /dev/null +++ b/server/src/mcp/repoHealth.ts @@ -0,0 +1,203 @@ +/** + * Repository Health facts(后端 MCP 运行时)。 + * + * 与 `electron/repoHealth.js`、`src/utils/repositoryHealth.ts` 是同一套算法的三份镜像: + * Core 的客观事实必须跨运行时一致,否则 UI、MCP 与插件会给出互相矛盾的数字。 + * 之所以必须镜像而不是共享模块:server 的 tsconfig `rootDir: "src"` 不允许 import 应用源码树, + * Electron 侧则是 CommonJS。改动本文件时请同步另外两份。 + * + * 边界一致:只输出客观事实与保守观测,不输出健康总分或「健康 / 不健康」结论。 + */ + +const MS_PER_DAY = 86_400_000; +const DAYS_PER_YEAR = 365.25; +/** 与 TS 版一致:超过一年无 push 只作为中性观测「No pushes in 12 months」。 */ +export const NO_RECENT_ACTIVITY_DAYS = 365; +const MIN_FREQUENCY_WINDOW_DAYS = 30; +const PRERELEASE_TOKENS = new Set([ + 'alpha', 'beta', 'rc', 'pre', 'prerelease', 'preview', 'dev', 'devel', + 'next', 'canary', 'snapshot', 'nightly', 'insider', 'unstable', +]); + +/** 供健康事实推导使用的最小仓库形状(后端不存储 GitHub 原生状态字段)。 */ +export interface HealthRepositoryInput { + id: number; + created_at?: string | null; + updated_at?: string | null; + pushed_at?: string | null; + stargazers_count?: number | null; + forks_count?: number | null; + forks?: number | null; + license?: string | null; + has_fetched_releases?: boolean; + archived?: boolean; + disabled?: boolean; + fork?: boolean; + is_template?: boolean; + open_issues_count?: number; + default_branch?: string; +} + +/** 健康事实推导所需的最小 Release 形状。 */ +export interface HealthReleaseInput { + repo_id?: number; + repository?: { id?: number } | null; + tag_name?: string | null; + published_at?: string | null; + prerelease?: boolean; +} + +export interface RepositoryHealthFacts { + archived: boolean | null; + disabled: boolean | null; + fork: boolean | null; + is_template: boolean | null; + created_at: string | null; + pushed_at: string | null; + age_days: number | null; + days_since_last_push: number | null; + release_count: number | null; + has_releases: boolean | null; + releases_fetched: boolean; + latest_release_at: string | null; + latest_stable_version: string | null; + latest_prerelease_version: string | null; + releases_per_year: number | null; + stars: number; + forks: number; + open_issues_count: number | null; + default_branch: string | null; + license: string | null; + signals: string[]; +} + +function toTimestamp(value?: string | null): number | null { + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function toCount(value: unknown): number { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +/** 布尔事实的三态化:只有记录里确实存在布尔值时才给出 true/false,否则为 null(未知)。 */ +function toTriState(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +/** 预发布判定:先信任 GitHub 标记,再按 tag 词元兜底(避免 `presto` 里的 `pre` 误判)。 */ +export function isPrereleaseRelease(release: Pick): boolean { + if (release?.prerelease === true) return true; + const tag = String(release?.tag_name ?? '').toLowerCase(); + if (!tag) return false; + return tag + .split(/[^a-z0-9]+/) + .filter(Boolean) + .some((token) => PRERELEASE_TOKENS.has(token.replace(/\d+$/, ''))); +} + +/** 取某仓库的 Release,按发布时间降序(不可解析的条目丢弃)。 */ +export function releasesForRepository( + releases: readonly HealthReleaseInput[] | undefined, + repositoryId: number, +): HealthReleaseInput[] { + return (Array.isArray(releases) ? releases : []) + .filter((release) => Number(release?.repository?.id ?? release?.repo_id) === Number(repositoryId)) + .filter((release) => toTimestamp(release.published_at) !== null) + .slice() + .sort((left, right) => (toTimestamp(right.published_at) as number) - (toTimestamp(left.published_at) as number)); +} + +/** + * 推导仓库健康事实。 + * + * @param repo 已存储的仓库记录。 + * @param releases 该仓库的 Release;不传表示调用方没有这项数据,Release 相关事实为 null。 + * @param now 计算「距今多久」的基准时间,便于测试注入。 + */ +export function deriveRepositoryHealthFacts( + repo: HealthRepositoryInput, + releases?: readonly HealthReleaseInput[], + now: number = Date.now(), +): RepositoryHealthFacts { + const provided = Array.isArray(releases); + const own = provided ? releasesForRepository(releases, repo?.id) : []; + const latest = own[0] ?? null; + const latestStable = own.find((release) => !isPrereleaseRelease(release)) ?? null; + const latestPrerelease = own.find((release) => isPrereleaseRelease(release)) ?? null; + + const createdTimestamp = toTimestamp(repo?.created_at); + const pushedTimestamp = toTimestamp(repo?.pushed_at || repo?.updated_at); + const ageDays = + createdTimestamp === null ? null : Math.max(0, Math.floor((now - createdTimestamp) / MS_PER_DAY)); + const daysSinceLastPush = + pushedTimestamp === null ? null : Math.max(0, Math.floor((now - pushedTimestamp) / MS_PER_DAY)); + const releasesPerYear = + ageDays === null + ? provided + ? round1(own.length) + : null + : round1(own.length / (Math.max(ageDays, MIN_FREQUENCY_WINDOW_DAYS) / DAYS_PER_YEAR)); + + const facts: RepositoryHealthFacts = { + archived: toTriState(repo?.archived), + disabled: toTriState(repo?.disabled), + fork: toTriState(repo?.fork), + is_template: toTriState(repo?.is_template), + + created_at: repo?.created_at ?? null, + pushed_at: repo?.pushed_at ?? null, + age_days: ageDays, + days_since_last_push: daysSinceLastPush, + + release_count: provided ? own.length : null, + has_releases: provided ? own.length > 0 : null, + releases_fetched: provided && repo?.has_fetched_releases === true, + latest_release_at: latest?.published_at ?? null, + latest_stable_version: latestStable?.tag_name ?? null, + latest_prerelease_version: latestPrerelease?.tag_name ?? null, + releases_per_year: releasesPerYear, + + stars: toCount(repo?.stargazers_count), + forks: repo?.forks_count != null ? toCount(repo.forks_count) : toCount(repo?.forks), + open_issues_count: repo?.open_issues_count === undefined ? null : toCount(repo.open_issues_count), + default_branch: repo?.default_branch ?? null, + license: repo?.license ?? null, + signals: [], + }; + + facts.signals = deriveRepositoryHealthSignals(facts); + return facts; +} + +/** Core 允许提供的保守观测,顺序固定(archived → disabled → no-releases → no-recent-activity)。 */ +export function deriveRepositoryHealthSignals(facts: RepositoryHealthFacts): string[] { + const signals: string[] = []; + if (facts.archived === true) signals.push('archived'); + if (facts.disabled === true) signals.push('disabled'); + if (facts.releases_fetched && facts.release_count === 0) signals.push('no-releases'); + if (facts.days_since_last_push !== null && facts.days_since_last_push >= NO_RECENT_ACTIVITY_DAYS) { + signals.push('no-recent-activity'); + } + return signals; +} + +export function isArchivedRepository(repo: Pick): boolean { + return repo?.archived === true; +} + +/** 与 TS 版一致:时间不可解析时返回 false(筛选语义下「未知」不算「近期活跃」)。 */ +export function hasRecentActivity( + repo: Pick, + now: number = Date.now(), +): boolean { + const pushed = toTimestamp(repo?.pushed_at) ?? toTimestamp(repo?.updated_at); + if (pushed === null) return false; + return now - pushed < NO_RECENT_ACTIVITY_DAYS * MS_PER_DAY; +} diff --git a/server/src/mcp/repoSearch.ts b/server/src/mcp/repoSearch.ts index 6d2114288..2bbf5b37b 100644 --- a/server/src/mcp/repoSearch.ts +++ b/server/src/mcp/repoSearch.ts @@ -2,6 +2,7 @@ * Pure repo search helpers for MCP (mirrors src/utils/repoSearch.ts). * Kept server-local to avoid coupling the Express package to the Vite app tree. */ +import { hasRecentActivity, isArchivedRepository } from './repoHealth.js'; /** * License 归一化的服务端镜像(与 src/utils/licenseFilter.ts 保持一致)。 @@ -52,6 +53,16 @@ export interface McpRepository { subscribed_to_releases?: boolean; owner?: { login: string; avatar_url?: string }; license?: string | null; + /** + * GitHub 原生状态字段。当前后端 schema 不存储这些列,因此运行时为 undefined; + * Repository Health 事实据此保持 `null`(未知)而不是断言「未归档」。 + */ + archived?: boolean; + disabled?: boolean; + fork?: boolean; + is_template?: boolean; + open_issues_count?: number; + default_branch?: string; } export interface McpSearchFilters { @@ -59,7 +70,7 @@ export interface McpSearchFilters { tags?: string[]; languages?: string[]; platforms?: string[]; - sortBy?: 'stars' | 'updated' | 'name' | 'starred'; + sortBy?: 'stars' | 'updated' | 'name' | 'starred' | 'created'; sortOrder?: 'desc' | 'asc'; minStars?: number; maxStars?: number; @@ -70,6 +81,10 @@ export interface McpSearchFilters { category?: string; /** SPDX id 过滤;含 {@link NO_LICENSE_SENTINEL} 表示「无/未声明 license」。 */ licenses?: string[]; + /** Repository Health 客观事实过滤(与 src/utils/repoSearch.ts 同一套语义)。 */ + healthArchived?: boolean; + healthRecentActivity?: boolean; + healthHasLicense?: boolean; limit?: number; offset?: number; } @@ -142,6 +157,20 @@ export function matchesRepoFilters(repo: McpRepository, filters: McpSearchFilter if (filters.maxStars !== undefined && (repo.stargazers_count ?? 0) > filters.maxStars) { return false; } + // Repository Health 事实过滤:与 Electron MCP / 前端同一套语义(见 repoHealth.ts)。 + if (filters.healthArchived !== undefined && isArchivedRepository(repo) !== filters.healthArchived) { + return false; + } + if ( + filters.healthRecentActivity !== undefined && + hasRecentActivity(repo) !== filters.healthRecentActivity + ) { + return false; + } + if (filters.healthHasLicense !== undefined) { + const hasLicense = normalizeLicense(repo.license) !== NO_LICENSE_SENTINEL; + if (hasLicense !== filters.healthHasLicense) return false; + } if (filters.category && filters.category !== 'all' && repo.custom_category !== filters.category) { return false; } @@ -158,6 +187,8 @@ function getSortValue(repo: McpRepository, sortBy: McpSearchFilters['sortBy']): return repo.name.toLowerCase(); case 'starred': return repo.starred_at ? new Date(repo.starred_at).getTime() : 0; + case 'created': + return repo.created_at ? new Date(repo.created_at).getTime() : 0; default: return new Date(repo.pushed_at || repo.updated_at || 0).getTime(); } diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 8e483fd7a..b88c6611f 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -98,7 +98,19 @@ export function registerMcpTools(server: McpServer): void { maxStars: z.number().optional(), isAnalyzed: z.boolean().optional(), isSubscribed: z.boolean().optional(), - sortBy: z.enum(['stars', 'updated', 'name', 'starred']).optional(), + healthArchived: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = only archived repositories, false = only non-archived'), + healthRecentActivity: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = pushed within the last 12 months'), + healthHasLicense: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = has a declared SPDX license, false = no declared license'), + sortBy: z.enum(['stars', 'updated', 'name', 'starred', 'created']).optional(), sortOrder: z.enum(['asc', 'desc']).optional(), limit: z.number().min(1).max(100).optional(), offset: z.number().min(0).optional(), @@ -116,6 +128,9 @@ export function registerMcpTools(server: McpServer): void { maxStars: args.maxStars, isAnalyzed: args.isAnalyzed, isSubscribed: args.isSubscribed, + healthArchived: args.healthArchived, + healthRecentActivity: args.healthRecentActivity, + healthHasLicense: args.healthHasLicense, sortBy: args.sortBy, sortOrder: args.sortOrder, limit: args.limit, @@ -187,7 +202,19 @@ export function registerMcpTools(server: McpServer): void { category: z.string().describe('custom_category value'), limit: z.number().min(1).max(100).optional(), offset: z.number().min(0).optional(), - sortBy: z.enum(['stars', 'updated', 'name', 'starred']).optional(), + healthArchived: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = only archived repositories, false = only non-archived'), + healthRecentActivity: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = pushed within the last 12 months'), + healthHasLicense: z + .boolean() + .optional() + .describe('Repository Health fact filter: true = has a declared SPDX license, false = no declared license'), + sortBy: z.enum(['stars', 'updated', 'name', 'starred', 'created']).optional(), sortOrder: z.enum(['asc', 'desc']).optional(), }, }, @@ -196,6 +223,9 @@ export function registerMcpTools(server: McpServer): void { category: args.category, limit: args.limit, offset: args.offset, + healthArchived: args.healthArchived, + healthRecentActivity: args.healthRecentActivity, + healthHasLicense: args.healthHasLicense, sortBy: args.sortBy, sortOrder: args.sortOrder, }); diff --git a/server/tests/mcp/parity.test.ts b/server/tests/mcp/parity.test.ts index 2da7b1285..b9eeb793a 100644 --- a/server/tests/mcp/parity.test.ts +++ b/server/tests/mcp/parity.test.ts @@ -75,6 +75,56 @@ describe('backend/Electron MCP parity', () => { expect(electron.evidence.evidenceFreshness).toEqual(backend.evidence.evidenceFreshness); }); + it('keeps Repository Health facts identical in both runtimes', async () => { + // Health 事实是三份镜像实现(src/utils/repositoryHealth.ts、electron/repoHealth.js、 + // server/src/mcp/repoHealth.ts)。这里锁定 Electron 与后端两份,防止数字口径漂移。 + const electronHealth = await import('../../../electron/repoHealth.js'); + const backendHealth = await import('../../src/mcp/repoHealth.js'); + const now = Date.parse('2026-09-17T00:00:00.000Z'); + const fixture = { + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + stargazers_count: 1500, + forks_count: 120, + created_at: '2020-09-17T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + pushed_at: '2026-09-01T00:00:00.000Z', + license: 'MIT', + has_fetched_releases: true, + archived: true, + disabled: false, + fork: false, + is_template: false, + open_issues_count: 32, + default_branch: 'main', + }; + const releases = [ + { repo_id: 1, tag_name: 'v1.0.0', published_at: '2025-01-01T00:00:00.000Z', prerelease: false }, + { repo_id: 1, tag_name: 'v2.0.0-rc1', published_at: '2026-06-01T00:00:00.000Z', prerelease: true }, + { repo_id: 99, tag_name: 'v9.0.0', published_at: '2026-07-01T00:00:00.000Z', prerelease: false }, + ]; + + const electron = electronHealth.deriveRepositoryHealthFacts(fixture, releases, now); + const backend = backendHealth.deriveRepositoryHealthFacts(fixture, releases, now); + + expect(electron).toEqual(backend); + expect(electron.signals).toEqual(['archived', 'no-recent-activity']); + }); + + it('keeps unknown health facts unknown in both runtimes', async () => { + const electronHealth = await import('../../../electron/repoHealth.js'); + const backendHealth = await import('../../src/mcp/repoHealth.js'); + const fixture = parityRepo({ id: 2, name: 'beta', full_name: 'acme/beta', stargazers_count: 5 }); + + const electron = electronHealth.deriveRepositoryHealthFacts(fixture, undefined, 0); + const backend = backendHealth.deriveRepositoryHealthFacts(fixture, undefined, 0); + + expect(electron).toEqual(backend); + expect(electron.archived).toBeNull(); + expect(electron.release_count).toBeNull(); + }); + it('orders tied repos identically by full_name on both ends', () => { const fixtures = [ parityRepo({ id: 1, name: 'zeta', full_name: 'acme/zeta', stargazers_count: 100 }), diff --git a/server/tests/mcp/tools.test.ts b/server/tests/mcp/tools.test.ts index e70b60f95..8cf0d07fe 100644 --- a/server/tests/mcp/tools.test.ts +++ b/server/tests/mcp/tools.test.ts @@ -83,6 +83,9 @@ describe('MCP tool registration', () => { 'maxStars', 'isAnalyzed', 'isSubscribed', + 'healthArchived', + 'healthRecentActivity', + 'healthHasLicense', 'sortBy', 'sortOrder', 'limit', @@ -113,6 +116,9 @@ describe('MCP tool registration', () => { 'category', 'limit', 'offset', + 'healthArchived', + 'healthRecentActivity', + 'healthHasLicense', 'sortBy', 'sortOrder', ]); diff --git a/src/components/InstallableAssetRecommendation.test.tsx b/src/components/InstallableAssetRecommendation.test.tsx new file mode 100644 index 000000000..a6b9955fb --- /dev/null +++ b/src/components/InstallableAssetRecommendation.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Release, ReleaseAsset } from '../types'; +import { InstallableAssetRecommendation } from './InstallableAssetRecommendation'; + +function asset(id: number, name: string, contentType = 'application/octet-stream'): ReleaseAsset { + return { + id, + name, + size: 1024 * id, + download_count: 0, + browser_download_url: `https://github.com/acme/alpha/releases/download/v1/${name}`, + content_type: contentType, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }; +} + +function makeRelease(assets: ReleaseAsset[]): Release { + return { + id: 1, + tag_name: 'v1.2.0', + name: 'v1.2.0', + body: null, + published_at: '2026-08-01T00:00:00.000Z', + html_url: 'https://github.com/acme/alpha/releases/tag/v1.2.0', + assets, + repository: { id: 1, full_name: 'acme/alpha', name: 'alpha' }, + }; +} + +/** 固定「当前设备」为 Windows,避免依赖 jsdom 的 UA 字符串。 */ +function stubDevicePlatform(platform: string): void { + Object.defineProperty(navigator, 'userAgentData', { + value: { platform }, + configurable: true, + writable: true, + }); +} + +afterEach(() => { + Reflect.deleteProperty(navigator as unknown as Record, 'userAgentData'); + vi.restoreAllMocks(); +}); + +describe('InstallableAssetRecommendation', () => { + it('recommends the best asset for this device and downloads only on click', async () => { + stubDevicePlatform('Windows'); + const onDownload = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + // 未点击前绝不下载、绝不执行任何东西 + expect(onDownload).not.toHaveBeenCalled(); + expect(screen.getByText('App-1.2.0-x64-setup.exe')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /download this build/i })); + + expect(onDownload).toHaveBeenCalledTimes(1); + expect(onDownload).toHaveBeenCalledWith( + expect.objectContaining({ assetId: 42, isSourceCode: false }), + ); + }); + + it('never claims the asset is safe and points at the manual asset list', () => { + stubDevicePlatform('Windows'); + render( + , + ); + + expect(screen.getByText(/no safety check is performed/i)).toBeInTheDocument(); + expect(screen.getByText(/pick another asset in the list below/i)).toBeInTheDocument(); + expect(screen.queryByText(/\bis safe\b/i)).not.toBeInTheDocument(); + }); + + it('renders nothing when no asset matches this device', () => { + stubDevicePlatform('Windows'); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId('installable-asset-recommendation')).not.toBeInTheDocument(); + }); + + it('renders nothing for releases that only ship source or metadata', () => { + stubDevicePlatform('Windows'); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('lists the remaining candidates and explains what was excluded', async () => { + stubDevicePlatform('Windows'); + const onDownload = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByText('Other candidates (1)')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Download' })); + expect(onDownload).toHaveBeenCalledWith(expect.objectContaining({ assetId: 2 })); + + // 排除说明里点名了各类非安装资产,用户不会以为它们凭空消失 + expect(screen.getByText(/debug symbols/i)).toBeInTheDocument(); + expect(screen.getByText(/other platforms or architectures/i)).toBeInTheDocument(); + }); + + it('renders candidates for every platform when the device platform cannot be detected', () => { + Object.defineProperty(navigator, 'userAgentData', { value: undefined, configurable: true }); + Object.defineProperty(navigator, 'platform', { value: 'Unknown', configurable: true }); + Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Unknown)', configurable: true }); + + render( + , + ); + + expect(screen.getByTestId('installable-asset-recommendation')).toBeInTheDocument(); + expect(screen.getByText('Other candidates (1)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/InstallableAssetRecommendation.tsx b/src/components/InstallableAssetRecommendation.tsx new file mode 100644 index 000000000..2d4258341 --- /dev/null +++ b/src/components/InstallableAssetRecommendation.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Download, Info, ShieldQuestion } from 'lucide-react'; +import type { Release } from '../types'; +import type { InstallableArchitecture, InstallableConfidence, InstallablePlatform } from '../types/installableAsset'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { getPlatformDisplayName, getPlatformIcon } from './platformMeta'; +import { buildReleaseDownloadLinks, type ReleaseDownloadLink } from '../utils/releaseDownloadLinks'; +import { detectInstallableAssets } from '../utils/installableAssets'; +import { detectDevicePlatformSync, resolveDeviceArchitecture } from '../utils/deviceTarget'; +import { formatFileSize } from '../utils/formatBytes'; + +/** + * 「这台设备能装哪个资产」的内置推荐块。 + * + * 与插件提供的 `ReleasePluginRecommendations` 的关系:那个是插件能力(需要用户点击分析、 + * 结果由插件负责),这里是 Core 的确定性识别,无需插件、不联网、每次渲染即得。 + * 两者互不替代,可以同时出现——插件可以基于 Health/资产事实给出自己的主观推荐。 + * + * 明确不做的事(roadmap §5.3): + * - 不自动下载、不自动运行安装程序;必须用户点击。 + * - 不声称安装包安全——只说明「按文件名识别为适配当前设备」。 + * - 不隐藏其他资产:识别不出来或平台不匹配的资产仍在下方资产表中可手动下载。 + */ +interface InstallableAssetRecommendationProps { + release: Release; + language: 'zh' | 'en'; + /** 复用 Release 资产表同一条下载链路(RPC / 认证下载 / 后端代理)。 */ + onDownload: (link: ReleaseDownloadLink) => void; +} + +const ARCHITECTURE_LABELS: Record = { + x64: 'x64', + arm64: 'arm64', + x86: 'x86', + universal: 'Universal', +}; + +const CONFIDENCE_LABELS: Record = { + high: { zh: '高置信', en: 'High confidence' }, + medium: { zh: '中等置信', en: 'Medium confidence' }, + low: { zh: '低置信', en: 'Low confidence' }, +}; + +export const InstallableAssetRecommendation: React.FC = ({ + release, + language, + onDownload, +}) => { + const t = (zh: string, en: string) => (language === 'zh' ? zh : en); + + // 平台可同步得到(Electron/Chromium 报告宿主 OS,Web 版报告浏览器所在设备)。 + const [platform, setPlatform] = useState(() => detectDevicePlatformSync()); + const [architecture, setArchitecture] = useState(undefined); + + useEffect(() => { + setPlatform(detectDevicePlatformSync()); + }, []); + + useEffect(() => { + let active = true; + void resolveDeviceArchitecture().then((resolved) => { + if (active) setArchitecture(resolved); + }); + return () => { + active = false; + }; + }, []); + + // 架构拿不到时不传 architecture —— 跳过架构过滤,并列展示候选而不是猜一个。 + const detection = useMemo( + () => + detectInstallableAssets(release.assets, { + platform: platform ?? undefined, + architecture, + }), + [release.assets, platform, architecture], + ); + + // 识别结果只带 assetId,下载仍走既有 link 模型,避免第二套下载逻辑。 + const linksByAssetId = useMemo(() => { + const map = new Map(); + for (const link of buildReleaseDownloadLinks(release)) { + if (link.assetId !== undefined) map.set(link.assetId, link); + } + return map; + }, [release]); + + if (detection.matches.length === 0) return null; + + const [best, ...alternatives] = detection.matches; + const bestLink = linksByAssetId.get(best.assetId); + const PlatformIcon = getPlatformIcon(best.platform); + + const describe = (match: typeof best) => { + // 平台显示名复用 platformMeta,避免再维护一份平台名表。 + const parts = [getPlatformDisplayName(match.platform)]; + if (match.architecture) parts.push(ARCHITECTURE_LABELS[match.architecture]); + parts.push(match.packageType); + return parts.join(' · '); + }; + + return ( +
+
+

{t('适配当前设备', 'Matches this device')}

+ {platform && ( + + {getPlatformDisplayName(platform)} + {architecture ? ` · ${ARCHITECTURE_LABELS[architecture]}` : ''} + + )} + + {language === 'zh' + ? CONFIDENCE_LABELS[best.confidence].zh + : CONFIDENCE_LABELS[best.confidence].en} + +
+ +
+
+
+ +
+ + {alternatives.length > 0 && ( +
+

+ {t(`其他可选资产(${alternatives.length})`, `Other candidates (${alternatives.length})`)} +

+
    + {alternatives.map((match) => { + const link = linksByAssetId.get(match.assetId); + return ( +
  • + + {match.fileName} + + {describe(match)} · {formatFileSize(match.size)} + + + +
  • + ); + })} +
+
+ )} + +

+

+ + {detection.excluded.length > 0 && ( +

+

+ )} +
+ ); +}; + +export default InstallableAssetRecommendation; diff --git a/src/components/RepositoryHealthPanel.test.tsx b/src/components/RepositoryHealthPanel.test.tsx new file mode 100644 index 000000000..8a0707534 --- /dev/null +++ b/src/components/RepositoryHealthPanel.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import type { Release, Repository } from '../types'; +import { RepositoryHealthPanel } from './RepositoryHealthPanel'; + +function makeRepo(overrides: Partial = {}): Repository { + return { + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + description: 'A test repository', + html_url: 'https://github.com/acme/alpha', + stargazers_count: 1500, + forks_count: 120, + forks: 120, + language: 'TypeScript', + created_at: '2020-09-17T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + pushed_at: '2026-09-01T00:00:00.000Z', + owner: { login: 'acme', avatar_url: '' }, + topics: [], + license: 'MIT', + ...overrides, + }; +} + +const release: Release = { + id: 1, + tag_name: 'v1.2.0', + name: 'v1.2.0', + body: null, + published_at: '2026-08-01T00:00:00.000Z', + html_url: 'https://github.com/acme/alpha/releases/tag/v1.2.0', + assets: [], + repository: { id: 1, full_name: 'acme/alpha', name: 'alpha' }, +}; + +describe('RepositoryHealthPanel', () => { + it('renders the four fixed fact groups', () => { + render(); + + for (const label of ['Activity', 'Maintenance', 'Community', 'Maturity']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('states that facts carry no overall health score', () => { + render(); + + expect( + screen.getByText(/objective facts with no overall score/i), + ).toBeInTheDocument(); + }); + + it('shows conservative signals as neutral observations', () => { + render( + , + ); + + // 「已归档」会同时出现在顶部观测徽章与 Maintenance 事实行中 + expect(screen.getAllByText('Archived').length).toBeGreaterThanOrEqual(2); + // 「最近无提交」是中性观测,不是「不健康」判定 + expect(screen.getByText('No pushes in 12 months')).toBeInTheDocument(); + expect(screen.queryByText(/unhealthy/i)).not.toBeInTheDocument(); + }); + + it('renders unknown facts as Unknown instead of guessing', () => { + render(); + + // contributors 属于 enrichment 事实,本地没有数据 + const contributorsRow = screen.getByText('Contributors').closest('div'); + expect(contributorsRow).not.toBeNull(); + expect(within(contributorsRow as HTMLElement).getByText('Unknown')).toBeInTheDocument(); + }); + + it('does not report "No releases" while release data is unavailable', () => { + render( + , + ); + + expect(screen.queryByText('No releases')).not.toBeInTheDocument(); + // 但归档等与 Release 无关的事实仍然展示 + expect(screen.getByText('Stars')).toBeInTheDocument(); + }); + + it('reports release facts once release data is supplied', () => { + render( + , + ); + + expect(screen.getByText('No releases')).toBeInTheDocument(); + }); +}); diff --git a/src/components/RepositoryHealthPanel.tsx b/src/components/RepositoryHealthPanel.tsx new file mode 100644 index 000000000..a3cec855d --- /dev/null +++ b/src/components/RepositoryHealthPanel.tsx @@ -0,0 +1,218 @@ +import React, { useMemo } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import { AlertTriangle, Archive, Ban, PackageOpen } from 'lucide-react'; +import type { Release, Repository } from '../types'; +import type { + RepositoryHealthFact, + RepositoryHealthFactId, + RepositoryHealthGroup, + RepositoryHealthSignalId, +} from '../types/health'; +import { Badge } from './ui/badge'; +import { + deriveRepositoryHealthSnapshot, + groupRepositoryHealthFacts, +} from '../utils/repositoryHealth'; + +/** + * Repository Health 事实面板。 + * + * 只展示客观事实与保守观测,**不展示任何健康总分或「健康 / 不健康」结论**—— + * 主观评分属于插件(见 docs/plans/2026-09-17-product-roadmap.md §4.3)。 + * 因此这里刻意不做颜色化的「好 / 坏」判定,未知事实显示为「未知」而不是猜测。 + */ +interface RepositoryHealthPanelProps { + repository: Repository; + /** 该仓库的本地 Release;用于推导 Release 相关事实,缺失时对应事实为未知。 */ + releases?: Release[]; + language: 'zh' | 'en'; +} + +/** 分组标题文案。i18n 重构(roadmap §13)后会迁入语言包。 */ +const GROUP_LABELS: Record = { + activity: { zh: '活跃度', en: 'Activity' }, + maintenance: { zh: '维护', en: 'Maintenance' }, + community: { zh: '社区', en: 'Community' }, + maturity: { zh: '成熟度', en: 'Maturity' }, +}; + +/** 事实标签文案。 */ +const FACT_LABELS: Record = { + pushedAt: { zh: '最近推送', en: 'Last push' }, + latestCommitAt: { zh: '默认分支最近提交', en: 'Latest commit' }, + recentCommitCount: { zh: '近期提交数', en: 'Recent commits' }, + hasReleases: { zh: '是否存在 Release', en: 'Has releases' }, + latestReleaseAt: { zh: '最近 Release', en: 'Latest release' }, + archived: { zh: '已归档', en: 'Archived' }, + disabled: { zh: '已停用', en: 'Disabled' }, + fork: { zh: 'Fork 仓库', en: 'Fork' }, + template: { zh: '模板仓库', en: 'Template' }, + license: { zh: 'License', en: 'License' }, + hasSecurityPolicy: { zh: 'Security Policy', en: 'Security policy' }, + hasCI: { zh: 'CI / GitHub Actions', en: 'CI / GitHub Actions' }, + hasReadme: { zh: 'README', en: 'README' }, + hasDocs: { zh: '文档目录', en: 'Docs' }, + stars: { zh: 'Stars', en: 'Stars' }, + forks: { zh: 'Forks', en: 'Forks' }, + openIssues: { zh: 'Open Issues', en: 'Open issues' }, + closedIssues: { zh: 'Closed Issues', en: 'Closed issues' }, + contributors: { zh: '贡献者', en: 'Contributors' }, + createdAt: { zh: '创建时间', en: 'Created' }, + ageDays: { zh: '仓库年龄', en: 'Repository age' }, + releaseCount: { zh: 'Release 数量', en: 'Releases' }, + releasesPerYear: { zh: '发布频率', en: 'Release frequency' }, + latestStableVersion: { zh: '最新稳定版本', en: 'Latest stable version' }, +}; + +/** 保守观测的文案与图标。`no-recent-activity` 用中性图标,避免暗示「不健康」。 */ +const SIGNAL_LABELS: Record = { + archived: { zh: '已归档', en: 'Archived' }, + disabled: { zh: '已停用', en: 'Disabled' }, + 'no-releases': { zh: '无 Release', en: 'No releases' }, + 'no-recent-activity': { zh: '近 12 个月无推送', en: 'No pushes in 12 months' }, +}; + +const SIGNAL_ICONS: Record> = { + archived: Archive, + disabled: Ban, + 'no-releases': PackageOpen, + 'no-recent-activity': AlertTriangle, +}; + +/** 千分位数字;非有限值原样回落,避免显示 NaN。 */ +function formatCount(value: number): string { + return Number.isFinite(value) ? value.toLocaleString('en-US') : '—'; +} + +/** 绝对日期(YYYY-MM-DD),用于 tooltip 与相对时间的兜底。 */ +function formatAbsoluteDate(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + return new Date(timestamp).toISOString().slice(0, 10); +} + +/** 事实值的展示文本。`undefined` 一律显示「未知」,不做任何推断。 */ +function formatFactValue( + fact: RepositoryHealthFact, + language: 'zh' | 'en', +): { text: string; title?: string; muted: boolean } { + const t = (zh: string, en: string) => (language === 'zh' ? zh : en); + const unknown = { text: t('未知', 'Unknown'), muted: true }; + + if (fact.value === undefined) return unknown; + + switch (fact.kind) { + case 'boolean': + if (fact.value === null) return { text: t('无', 'None'), muted: true }; + return fact.value + ? { text: t('是', 'Yes'), muted: false } + : { text: t('否', 'No'), muted: true }; + case 'count': + if (fact.value === null) return { text: '—', muted: true }; + if (fact.id === 'releasesPerYear') { + return { text: t(`${formatCount(fact.value as number)} 次/年`, `${formatCount(fact.value as number)} / year`), muted: false }; + } + return { text: formatCount(fact.value as number), muted: false }; + case 'duration': { + if (fact.value === null) return { text: '—', muted: true }; + const days = fact.value as number; + const years = Math.round((days / 365.25) * 10) / 10; + return { + text: t(`${formatCount(days)} 天(约 ${years} 年)`, `${formatCount(days)} days (~${years} years)`), + muted: false, + }; + } + case 'date': { + if (fact.value === null) return { text: t('无', 'None'), muted: true }; + const raw = String(fact.value); + const timestamp = Date.parse(raw); + if (!Number.isFinite(timestamp)) return { text: raw, muted: false }; + return { + text: formatDistanceToNow(timestamp, { + addSuffix: true, + locale: language === 'zh' ? zhCN : undefined, + }), + title: formatAbsoluteDate(raw), + muted: false, + }; + } + case 'text': + default: + if (fact.value === null) return { text: t('无', 'None'), muted: true }; + return { text: String(fact.value), muted: false }; + } +} + +export const RepositoryHealthPanel: React.FC = ({ + repository, + releases, + language, +}) => { + const t = (zh: string, en: string) => (language === 'zh' ? zh : en); + + // 纯函数推导:无网络请求,Release 未同步时相关事实自动成为「未知」。 + const groups = useMemo(() => { + const snapshot = deriveRepositoryHealthSnapshot(repository, releases); + return { snapshot, views: groupRepositoryHealthFacts(snapshot) }; + }, [repository, releases]); + + const { snapshot, views } = groups; + + return ( +
+
+

{t('仓库健康事实', 'Repository health facts')}

+ {snapshot.signals.map((signal) => { + const Icon = SIGNAL_ICONS[signal.id]; + return ( + + + ); + })} +
+ +
+ {views.map(({ group, facts }) => ( +
+

+ {language === 'zh' ? GROUP_LABELS[group].zh : GROUP_LABELS[group].en} +

+
+ {facts.map((fact) => { + const formatted = formatFactValue(fact, language); + const label = + language === 'zh' ? FACT_LABELS[fact.id].zh : FACT_LABELS[fact.id].en; + return ( +
+
{label}
+
+ {formatted.text} +
+
+ ); + })} +
+
+ ))} +
+ +

+ {t( + '以上为客观事实,不含健康总分。「未知」表示尚未获得该事实。', + 'These are objective facts with no overall score. “Unknown” means the fact has not been obtained yet.', + )} +

+
+ ); +}; + +export default RepositoryHealthPanel; diff --git a/src/components/RepositoryReleaseSheet.test.tsx b/src/components/RepositoryReleaseSheet.test.tsx index 7d36068ee..0e67e0138 100644 --- a/src/components/RepositoryReleaseSheet.test.tsx +++ b/src/components/RepositoryReleaseSheet.test.tsx @@ -89,6 +89,10 @@ const renderSheet = () => render( /> ); +// 侧栏现在还会渲染 Repository Health 事实面板,面板里的「最新稳定版本」同样是 tag 名? +// 因此针对 Release 条目的查询必须限定在 Release 列表容器内,避免与事实面板串台? +const releaseList = () => within(screen.getByTestId('release-list')); + describe('RepositoryReleaseSheet', () => { beforeEach(() => { vi.clearAllMocks(); @@ -105,15 +109,15 @@ describe('RepositoryReleaseSheet', () => { renderSheet(); expect(hookMocks.loadReleases).toHaveBeenCalledOnce(); - expect(screen.getByText('v1')).toBeInTheDocument(); - expect(screen.queryByText('v11')).not.toBeInTheDocument(); + expect(releaseList().getByText('v1')).toBeInTheDocument(); + expect(releaseList().queryByText('v11')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Release 分页 next page' })); - expect(screen.getByText('v11')).toBeInTheDocument(); - expect(screen.queryByText('v1')).not.toBeInTheDocument(); + expect(releaseList().getByText('v11')).toBeInTheDocument(); + expect(releaseList().queryByText('v1')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Release 分页 previous page' })); - await user.click(screen.getByText('v1').closest('button')!); + await user.click(releaseList().getByText('v1').closest('button')!); expect(screen.getByText('Source code (v1.zip)')).toBeInTheDocument(); const zipRow = screen.getByText('Source code (v1.zip)').closest('tr'); @@ -132,7 +136,7 @@ describe('RepositoryReleaseSheet', () => { const user = userEvent.setup(); renderSheet(); - await user.click(screen.getByText('v1').closest('button')!); + await user.click(releaseList().getByText('v1').closest('button')!); await user.click(screen.getByRole('tab', { name: '更新日志' })); expect(screen.getByTestId('markdown')).toHaveAttribute('data-font-size', 'small'); @@ -153,17 +157,17 @@ describe('RepositoryReleaseSheet', () => { }]; renderSheet(); - await user.click(screen.getByText('v1').closest('button')!); + await user.click(releaseList().getByText('v1').closest('button')!); - // 可识别平台的资产渲染品牌徽章(与 ReleaseCard 的 AssetLeadingIcon 一致)。 - // getAllByTitle:simple-icons 的 svg 内部也带 ,需按徽章 class 过滤出外层 span。 + // 可识别平台的资产渲染品牌徽章(与 ReleaseCard ?AssetLeadingIcon 一致)? + // getAllByTitle:simple-icons ?svg 内部也带 <title>,需按徽?class 过滤出外?span? const getBadge = (title: string) => screen.getAllByTitle(title).find((el) => el.classList.contains('asset-platform-badge')); expect(getBadge('macOS')).toBeDefined(); expect(getBadge('Windows')).toBeDefined(); expect(getBadge('Linux')).toBeDefined(); - // 平台不可识别的资产回退到通用下载图标,不猜平台 + // 平台不可识别的资产回退到通用下载图标,不猜平? const zipRow = screen.getByText('myapp-1.0.zip').closest('tr'); expect(zipRow).not.toBeNull(); expect(zipRow!.querySelector('.asset-platform-badge')).toBeNull(); @@ -175,7 +179,7 @@ describe('RepositoryReleaseSheet', () => { hookMocks.state.isRpcEnabled = true; renderSheet(); - await user.click(screen.getByText('v1').closest('button')!); + await user.click(releaseList().getByText('v1').closest('button')!); await user.click(screen.getAllByRole('button', { name: '下载' })[0]); expect(hookMocks.downloadAsset).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/src/components/RepositoryReleaseSheet.tsx b/src/components/RepositoryReleaseSheet.tsx index 6f4ec7172..e83d5da41 100644 --- a/src/components/RepositoryReleaseSheet.tsx +++ b/src/components/RepositoryReleaseSheet.tsx @@ -9,12 +9,15 @@ import { useAppStore } from '../store/useAppStore'; import { useRepositoryReleaseSheet } from '../features/repositories/hooks/useRepositoryReleaseSheet'; import { computeRpcDownloadKey } from '../hooks/useReleaseArtifactActions'; import { buildReleaseDownloadLinks, type ReleaseDownloadLink } from '../utils/releaseDownloadLinks'; +import { formatFileSize } from '../utils/formatBytes'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'; import { Button } from './ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { ReleasePluginRecommendations } from './ReleasePluginRecommendations'; +import { RepositoryHealthPanel } from './RepositoryHealthPanel'; +import { InstallableAssetRecommendation } from './InstallableAssetRecommendation'; const RELEASES_PER_PAGE = 10; const ASSETS_PER_PAGE = 8; @@ -26,14 +29,6 @@ interface RepositoryReleaseSheetProps { repository: Repository; } -const formatFileSize = (bytes: number | null): string => { - if (bytes === null) return '—'; - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; -}; - const Pagination: React.FC<{ page: number; totalPages: number; @@ -176,6 +171,7 @@ const ReleaseContent: React.FC<{ </TabsList> <TabsContent value="assets" className="mt-3"> <ReleasePluginRecommendations release={release} repository={repository} language={language} /> + <InstallableAssetRecommendation release={release} language={language} onDownload={onDownload} /> <ReleaseAssetsTable release={release} assetPage={assetPage} @@ -305,7 +301,14 @@ export const RepositoryReleaseSheet: React.FC<RepositoryReleaseSheetProps> = ({ </a> </Button> </div> - <div className="min-h-0 flex-1 overflow-y-auto pr-1"> + <RepositoryHealthPanel + repository={repository} + // 拉取中/拉取失败时不把「本地没有 Release」当成事实——那样会把网络故障 + // 误报成「该仓库没有 Release」。此时相关事实显示为「未知」。 + releases={isLoading || error ? undefined : releases} + language={language} + /> + <div className="min-h-0 flex-1 overflow-y-auto pr-1" data-testid="release-list"> {isLoading ? ( <div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground" role="status" aria-live="polite"> <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" /> diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index b3486278f..f0d1258ef 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,6 +1,6 @@ import { Input } from './ui/input'; import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Search, X, SlidersHorizontal, CheckCircle, Bell, BellOff, Bot, Edit3, Lock, Unlock, AlertCircle, ChevronDown, RefreshCw, Clock, ArrowDown, ArrowUp, History } from 'lucide-react'; +import { Search, X, SlidersHorizontal, CheckCircle, Bell, BellOff, Bot, Edit3, Lock, Unlock, AlertCircle, ChevronDown, RefreshCw, Clock, ArrowDown, ArrowUp, History, Archive } from 'lucide-react'; import { getPlatformDisplayName, getPlatformIcon } from './platformMeta'; import { useAppStore, getAllCategories } from '../store/useAppStore'; import { useShallow } from 'zustand/react/shallow'; @@ -23,13 +23,14 @@ import { DropdownMenuTrigger, } from './ui/dropdown-menu'; -type SortBy = 'stars' | 'updated' | 'name' | 'starred'; +type SortBy = 'stars' | 'updated' | 'name' | 'starred' | 'created'; const sortOptions: { value: SortBy; labelZh: string; labelEn: string }[] = [ { value: 'stars', labelZh: '按星标排序', labelEn: 'Sort by Stars' }, { value: 'updated', labelZh: '按更新排序', labelEn: 'Sort by Updated' }, { value: 'name', labelZh: '按名称排序', labelEn: 'Sort by Name' }, { value: 'starred', labelZh: '按加星时间排序', labelEn: 'Sort by Starred Time' }, + { value: 'created', labelZh: '按创建时间排序', labelEn: 'Sort by Created' }, ]; interface SortByDropdownProps { @@ -592,6 +593,9 @@ export const SearchBar: React.FC = () => { isEdited: undefined, isCategoryLocked: undefined, analysisFailed: undefined, + healthArchived: undefined, + healthRecentActivity: undefined, + healthHasLicense: undefined, }); }; @@ -606,7 +610,10 @@ export const SearchBar: React.FC = () => { (searchFilters.isSubscribed !== undefined ? 1 : 0) + (searchFilters.isEdited !== undefined ? 1 : 0) + (searchFilters.isCategoryLocked !== undefined ? 1 : 0) + - (searchFilters.analysisFailed !== undefined ? 1 : 0); + (searchFilters.analysisFailed !== undefined ? 1 : 0) + + (searchFilters.healthArchived !== undefined ? 1 : 0) + + (searchFilters.healthRecentActivity !== undefined ? 1 : 0) + + (searchFilters.healthHasLicense !== undefined ? 1 : 0); // 平台图标与显示名统一由 platformMeta 模块提供 @@ -1327,6 +1334,58 @@ export const SearchBar: React.FC = () => { ))} </div> </div> + + {/* Repository Health 客观事实过滤(见 src/utils/repositoryHealth.ts)。 + 这里刻意不放「健康 / 不健康」之类主观筛选,只按可验证事实过滤。 */} + <div> + <h4 className="text-sm font-medium text-foreground dark:text-foreground mb-3"> + {t('仓库健康事实', 'Repository Health Facts')} + </h4> + <div className="flex flex-wrap gap-2"> + <Button + onClick={() => setSearchFilters({ + healthArchived: searchFilters.healthArchived === true ? undefined : true, + })} + aria-pressed={searchFilters.healthArchived === true} + title={t('只显示已归档的仓库', 'Show only archived repositories')} + variant="ghost" + className={`${filterChipBaseClass} ${ + searchFilters.healthArchived === true ? filterChipActiveClass : filterChipInactiveClass + }`} + > + <Archive className="w-4 h-4" /> + <span>{t('已归档', 'Archived')}</span> + </Button> + <Button + onClick={() => setSearchFilters({ + healthRecentActivity: searchFilters.healthRecentActivity === true ? undefined : true, + })} + aria-pressed={searchFilters.healthRecentActivity === true} + title={t('只显示近 12 个月内有推送的仓库', 'Show only repositories pushed within the last 12 months')} + variant="ghost" + className={`${filterChipBaseClass} ${ + searchFilters.healthRecentActivity === true ? filterChipActiveClass : filterChipInactiveClass + }`} + > + <Clock className="w-4 h-4" /> + <span>{t('近 12 个月有推送', 'Pushed in 12 months')}</span> + </Button> + <Button + onClick={() => setSearchFilters({ + healthHasLicense: searchFilters.healthHasLicense === false ? undefined : false, + })} + aria-pressed={searchFilters.healthHasLicense === false} + title={t('只显示未声明许可证的仓库', 'Show only repositories without a declared license')} + variant="ghost" + className={`${filterChipBaseClass} ${ + searchFilters.healthHasLicense === false ? filterChipActiveClass : filterChipInactiveClass + }`} + > + <AlertCircle className="w-4 h-4" /> + <span>{t('未声明许可证', 'No declared license')}</span> + </Button> + </div> + </div> </div> )} diff --git a/src/features/repositories/hooks/useSearchActions.ts b/src/features/repositories/hooks/useSearchActions.ts index 052f10e9d..c577a508b 100644 --- a/src/features/repositories/hooks/useSearchActions.ts +++ b/src/features/repositories/hooks/useSearchActions.ts @@ -60,6 +60,14 @@ export const mergeStarredRepositories = ( topics: newRepo.topics, // 回填历史仓库缺失的 license 字段(GitHub 源元数据,跟随 newRepo) license: newRepo.license ?? null, + // GitHub 原生状态字段:跟随 newRepo 刷新(归档/停用状态会随上游变化)。 + // 源缺失时保留本地已获得的值,避免把已有 Health 事实退化成「未知」。 + archived: newRepo.archived ?? existing.archived, + disabled: newRepo.disabled ?? existing.disabled, + fork: newRepo.fork ?? existing.fork, + is_template: newRepo.is_template ?? existing.is_template, + open_issues_count: newRepo.open_issues_count ?? existing.open_issues_count, + default_branch: newRepo.default_branch ?? existing.default_branch, }; } return newRepo; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 3e8ceefc1..735b6cda5 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -3,6 +3,7 @@ import { isToolCallCapableApiType } from '../constants/aiCapabilities'; import { backend } from './backendAdapter'; import { buildApiUrl, buildFinalApiUrl } from '../utils/apiUrlBuilder'; import { NO_LICENSE_SENTINEL, normalizeLicense } from '../utils/licenseFilter'; +import { deriveRepositoryHealthSnapshot } from '../utils/repositoryHealth'; import { logger } from './logger'; interface OpenAIResponseContentPart { @@ -1662,6 +1663,42 @@ ${previousOutput} `.trim(); } + /** + * Repository Health 客观事实摘要,供 AI 分析提示复用。 + * + * 只输出中性事实与保守观测,并显式标注「不是质量结论」:模型不应仅因为最近没有 + * 提交就把成熟稳定项目判为劣质。主观评分属于插件(roadmap §4.3)。 + * 分析路径拿不到 Release 列表,因此这里不包含 Release 相关事实,绝不猜测。 + */ + private formatRepositoryHealthFacts(repository: Repository): string { + const zh = this.language === 'zh'; + const yes = zh ? '是' : 'yes'; + const no = zh ? '否' : 'no'; + const unknown = zh ? '未知' : 'unknown'; + const toDate = (value?: string | null): string => { + if (!value || !Number.isFinite(Date.parse(value))) return unknown; + return new Date(value).toISOString().slice(0, 10); + }; + + const snapshot = deriveRepositoryHealthSnapshot(repository); + const status = [ + `${zh ? '已归档' : 'archived'}=${snapshot.archived ? yes : no}`, + `${zh ? '已停用' : 'disabled'}=${snapshot.disabled === true ? yes : no}`, + `${zh ? 'Fork' : 'fork'}=${snapshot.fork ? yes : no}`, + `${zh ? '模板' : 'template'}=${snapshot.isTemplate ? yes : no}`, + ].join(', '); + const observations = snapshot.signals.length + ? snapshot.signals.map((signal) => signal.id).join(', ') + : (zh ? '无' : 'none'); + + return [ + `${zh ? '状态' : 'Status'}: ${status}`, + `${zh ? '创建时间' : 'Created'}: ${toDate(repository.created_at)} | ${zh ? '最近推送' : 'Last push'}: ${toDate(repository.pushed_at)}`, + `License: ${repository.license || (zh ? '未声明' : 'none')} | ${zh ? 'Open Issues' : 'Open issues'}: ${repository.open_issues_count ?? unknown}`, + `${zh ? '保守观测' : 'Observations'}: ${observations}`, + ].join('\n'); + } + private createCustomAnalysisPrompt(repository: Repository, readmeContent: string, customCategories?: string[], categoryHints?: string): string { const repoInfo = ` ${this.language === 'zh' ? '仓库名称' : 'Repository Name'}: ${repository.full_name} @@ -1669,6 +1706,8 @@ ${this.language === 'zh' ? '描述' : 'Description'}: ${this.sanitizeForPrompt(r ${this.language === 'zh' ? '编程语言' : 'Programming Language'}: ${repository.language || (this.language === 'zh' ? '未知' : 'Unknown')} ${this.language === 'zh' ? 'Star数' : 'Stars'}: ${repository.stargazers_count} ${this.language === 'zh' ? '主题标签' : 'Topics'}: ${repository.topics?.join(', ') || (this.language === 'zh' ? '无' : 'None')} +${this.language === 'zh' ? '客观事实(中性,不代表质量结论)' : 'Objective facts (neutral, not a quality verdict)'}: +${this.sanitizeForPrompt(this.formatRepositoryHealthFacts(repository))} ${this.language === 'zh' ? 'README内容 (前2000字符)' : 'README Content (first 2000 characters)'}: ${this.sanitizeForPrompt(readmeContent.substring(0, 2000))} @@ -1701,6 +1740,8 @@ ${this.language === 'zh' ? '描述' : 'Description'}: ${this.sanitizeForPrompt(r ${this.language === 'zh' ? '编程语言' : 'Programming Language'}: ${repository.language || (this.language === 'zh' ? '未知' : 'Unknown')} ${this.language === 'zh' ? 'Star数' : 'Stars'}: ${repository.stargazers_count} ${this.language === 'zh' ? '主题标签' : 'Topics'}: ${repository.topics?.join(', ') || (this.language === 'zh' ? '无' : 'None')} +${this.language === 'zh' ? '客观事实(中性,不代表质量结论)' : 'Objective facts (neutral, not a quality verdict)'}: +${this.sanitizeForPrompt(this.formatRepositoryHealthFacts(repository))} ${this.language === 'zh' ? 'README内容 (前2000字符)' : 'README Content (first 2000 characters)'}: ${this.sanitizeForPrompt(readmeContent.substring(0, 2000))} diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index b3cb6f40c..ea2b9a6e1 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -114,6 +114,13 @@ export interface GitHubRepoDetailRead { topics: string[]; owner: { login: string; avatar_url: string }; license: string | null; + /** GitHub 原生状态字段:Repository Health 的客观事实来源(详情路径免费携带)。 */ + archived?: boolean; + disabled?: boolean; + fork?: boolean; + is_template?: boolean; + open_issues_count?: number; + default_branch?: string; } interface GitHubStarredItem { @@ -190,6 +197,12 @@ function mapRestRepoDetail(data: Record<string, unknown>): GitHubRepoDetailRead avatar_url: typeof owner.avatar_url === 'string' ? owner.avatar_url : '', }, license: toLicenseSpdxId(data.license), + archived: data.archived === true, + disabled: data.disabled === true, + fork: data.fork === true, + is_template: data.is_template === true, + open_issues_count: typeof data.open_issues_count === 'number' ? data.open_issues_count : 0, + default_branch: typeof data.default_branch === 'string' ? data.default_branch : '', }; } @@ -225,6 +238,18 @@ function mapGraphqlRepoDetail(node: Record<string, unknown>): GitHubRepoDetailRe avatar_url: typeof owner.avatarUrl === 'string' ? owner.avatarUrl : '', }, license: licenseInfo && typeof licenseInfo.spdxId === 'string' ? licenseInfo.spdxId : null, + archived: node.isArchived === true, + disabled: node.isDisabled === true, + fork: node.isFork === true, + is_template: node.isTemplate === true, + open_issues_count: + typeof (node.openIssues as { totalCount?: unknown } | undefined)?.totalCount === 'number' + ? (node.openIssues as { totalCount: number }).totalCount + : 0, + default_branch: + typeof (node.defaultBranchRef as { name?: unknown } | null | undefined)?.name === 'string' + ? (node.defaultBranchRef as { name: string }).name + : '', }; } @@ -1300,7 +1325,7 @@ export class GitHubApiService { const [owner, name] = fullName.split('/'); return `a${idx}: repository(owner: "${escapeGraphQlString(owner)}", name: "${escapeGraphQlString(name)}") { ...WeeklyRepoDetailFragment }`; }); - const query = `query WeeklyRepoBatch {\n${aliases.join('\n')}\n}\nfragment WeeklyRepoDetailFragment on Repository {\n databaseId\n name\n nameWithOwner\n description\n url\n stargazerCount\n forkCount\n primaryLanguage { name }\n createdAt\n updatedAt\n pushedAt\n repositoryTopics(first: 20) { nodes { topic { name } } }\n owner { login avatarUrl }\n licenseInfo { spdxId }\n}`; + const query = `query WeeklyRepoBatch {\n${aliases.join('\n')}\n}\nfragment WeeklyRepoDetailFragment on Repository {\n databaseId\n name\n nameWithOwner\n description\n url\n stargazerCount\n forkCount\n primaryLanguage { name }\n createdAt\n updatedAt\n pushedAt\n repositoryTopics(first: 20) { nodes { topic { name } } }\n owner { login avatarUrl }\n licenseInfo { spdxId }\n isArchived\n isDisabled\n isFork\n isTemplate\n openIssues { totalCount }\n defaultBranchRef { name }\n}`; const response = await this.makeRequest<{ data?: Record<string, unknown> | null; errors?: Array<{ message?: string }> }>( '/graphql', { diff --git a/src/store/schema.ts b/src/store/schema.ts index f2e83ec92..fbe99fc1d 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -34,6 +34,9 @@ export const initialSearchFilters: SearchFilters = { isEdited: undefined, isCategoryLocked: undefined, analysisFailed: undefined, + healthArchived: undefined, + healthRecentActivity: undefined, + healthHasLicense: undefined, }; export const initialGistSearchFilters: GistSearchFilters = { diff --git a/src/types/health.ts b/src/types/health.ts new file mode 100644 index 000000000..4394806b6 --- /dev/null +++ b/src/types/health.ts @@ -0,0 +1,148 @@ +/** + * Repository Health Core —— 客观事实模型。 + * + * 设计边界(见 docs/plans/2026-09-17-product-roadmap.md §4): + * - Core 只提供**可验证的事实**与保守状态,不提供 0–100 健康总分,也不做主观结论。 + * - 「最近没有提交」不等于「不健康」:成熟稳定项目长期不更新是正常状态, + * 因此本模块只输出中性观测(例如 `no-recent-activity`),由 UI/插件决定如何解释。 + * - 依赖网络补全的事实(贡献者数、closed issues、Security Policy、CI、README/文档、 + * 默认分支最近提交)在未补全时为 `undefined`,显式表示「未知」,绝不猜测。 + * + * 所有事实均可由 `Repository` + 本地 `Release[]`(+ 可选 enrichment)纯函数推导, + * 因此筛选、排序、Discovery、AI、MCP 与 Plugin API 复用同一份结果,不需要额外网络请求。 + */ + +/** UI 分组:Activity / Maintenance / Community / Maturity。 */ +export type RepositoryHealthGroup = 'activity' | 'maintenance' | 'community' | 'maturity'; + +/** 事实来源:仓库字段、本地 Release、或需要联网补全的 enrichment。 */ +export type RepositoryHealthFactSource = 'repository' | 'releases' | 'enrichment'; + +/** + * 事实数据类型,决定 UI 的格式化方式与筛选行为。 + * `duration` 用于「多久以前」这类派生时间跨度(值为 epoch 毫秒)。 + */ +export type RepositoryHealthFactKind = 'boolean' | 'date' | 'count' | 'text' | 'duration'; + +export type RepositoryHealthFactId = + // Activity + | 'pushedAt' + | 'latestCommitAt' + | 'recentCommitCount' + | 'hasReleases' + | 'latestReleaseAt' + // Maintenance + | 'archived' + | 'disabled' + | 'fork' + | 'template' + | 'license' + | 'hasSecurityPolicy' + | 'hasCI' + | 'hasReadme' + | 'hasDocs' + // Community + | 'stars' + | 'forks' + | 'openIssues' + | 'closedIssues' + | 'contributors' + // Maturity + | 'createdAt' + | 'ageDays' + | 'releaseCount' + | 'releasesPerYear' + | 'latestStableVersion'; + +/** 单个事实。`value === null` 表示已知但没有值(例如无 license);`undefined` 表示未知。 */ +export interface RepositoryHealthFact { + id: RepositoryHealthFactId; + group: RepositoryHealthGroup; + kind: RepositoryHealthFactKind; + /** `null` = 已知且为空(如无 license);`undefined` = 尚未获得该事实。 */ + value: boolean | number | string | null | undefined; + source: RepositoryHealthFactSource; +} + +/** + * Core 允许提供的保守状态。这些都是**观测**而不是评分: + * - `archived` / `disabled`:GitHub 上的客观状态。 + * - `no-releases`:仅在已确认同步过 Release(`has_fetched_releases === true`)时才给出。 + * - `no-recent-activity`:最近一次 push 超过 `NO_RECENT_ACTIVITY_DAYS`,中性描述。 + */ +export type RepositoryHealthSignalId = 'archived' | 'disabled' | 'no-releases' | 'no-recent-activity'; + +export interface RepositoryHealthSignal { + id: RepositoryHealthSignalId; + /** 触发该观测的时间(epoch 毫秒);非时间型观测为 null。 */ + since: number | null; + /** 触发该观测的原始事实值,便于 UI 展示而不必二次推导。 */ + detail?: string | null; +} + +/** + * 需要联网补全的事实。 + * 未提供 enrichment 时,快照中对应字段保持 `undefined`(未知)。 + */ +export interface RepositoryHealthEnrichment { + latestCommitAt?: string | null; + recentCommitCount?: number; + closedIssues?: number; + contributors?: number; + hasSecurityPolicy?: boolean; + hasCI?: boolean; + hasReadme?: boolean; + hasDocs?: boolean; +} + +/** + * 统一健康事实快照(roadmap §4.1 建议模型 + 补充字段)。 + * 与 `Repository` 分离:快照是派生的只读投影,不进入持久化仓库实体, + * 因此不会影响后端同步指纹(Issue #304 的哈希契约)。 + */ +export interface RepositoryHealthSnapshot { + archived: boolean; + disabled?: boolean; + fork: boolean; + isTemplate: boolean; + + createdAt: string; + pushedAt: string | null; + latestCommitAt?: string | null; + recentCommitCount?: number; + + hasReleases: boolean; + /** 是否已确认同步过该仓库的 Release。为 false 时 `hasReleases === false` 只代表「尚未拉取」。 */ + releasesFetched: boolean; + latestReleaseAt: string | null; + releaseCount: number; + /** 粗略发布频率:release 总数 / 仓库年龄(年),保留一位小数;无法计算时为 null(未知)。 */ + releasesPerYear: number | null; + latestStableVersion: string | null; + latestPrereleaseVersion: string | null; + + stars: number; + forks: number; + openIssues?: number; + closedIssues?: number; + contributors?: number; + + license?: string | null; + hasSecurityPolicy?: boolean; + hasCI?: boolean; + hasReadme?: boolean; + hasDocs?: boolean; + + /** 仓库年龄(天)。createdAt 不可解析时为 null。 */ + ageDays: number | null; + /** 距离最近一次 push 的天数。pushedAt 不可解析时为 null。 */ + daysSinceLastPush: number | null; + /** 保守状态,顺序稳定(archived → disabled → no-releases → no-recent-activity)。 */ + signals: RepositoryHealthSignal[]; +} + +/** 供 UI 渲染的分组视图模型。 */ +export interface RepositoryHealthGroupView { + group: RepositoryHealthGroup; + facts: RepositoryHealthFact[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index e9385dd82..2f7662189 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,38 @@ import type { ThemePresetId } from '../constants/themePresets'; import type { RepositoryChatSettings } from './repositoryChat'; export type { RepositoryChatSettings } from './repositoryChat'; +export type { + RepositoryHealthEnrichment, + RepositoryHealthFact, + RepositoryHealthFactId, + RepositoryHealthFactKind, + RepositoryHealthFactSource, + RepositoryHealthGroup, + RepositoryHealthGroupView, + RepositoryHealthSignal, + RepositoryHealthSignalId, + RepositoryHealthSnapshot, +} from './health'; +export type { + InstallableArchitecture, + InstallableAsset, + InstallableAssetDetectionOptions, + InstallableAssetDetectionResult, + InstallableConfidence, + InstallablePackageType, + InstallablePlatform, +} from './installableAsset'; +export type { + ImportCandidateConfidence, + ImportCandidateMatchedBy, + ImportCandidateStatus, + ImportedRepositoryCandidate, + ImportFailureReason, + ImportSource, + RepositoryImportExtractionOptions, + RepositoryImportExtractionResult, + RepositoryImportInputError, +} from './repositoryImport'; export interface Repository { id: number; @@ -22,6 +54,20 @@ export interface Repository { avatar_url: string; }; topics: string[]; + /** + * GitHub 原生状态字段。`/user/starred` 原始响应本就包含这些字段, + * 这里把它们纳入类型以便 Repository Health 与筛选复用。 + * + * 后端不持久化这些字段,因此 `src/utils/repositoryMerge.ts` 把它们同时列入 + * `CLIENT_ONLY_REPOSITORY_FIELDS`(不参与后端同步指纹,避免 Issue #304 的哈希抖动) + * 与 `LOCAL_REPOSITORY_FIELDS`(拉取时保留本地值,不被后端响应清空)。 + */ + archived?: boolean; + disabled?: boolean; + fork?: boolean; + is_template?: boolean; + open_issues_count?: number; + default_branch?: string; ai_summary?: string; ai_tags?: string[]; ai_platforms?: string[]; @@ -354,7 +400,7 @@ export interface SearchFilters { tags: string[]; languages: string[]; platforms: string[]; // 新增:平台过滤 - sortBy: 'stars' | 'updated' | 'name' | 'starred'; + sortBy: 'stars' | 'updated' | 'name' | 'starred' | 'created'; sortOrder: 'desc' | 'asc'; minStars?: number; maxStars?: number; @@ -365,6 +411,15 @@ export interface SearchFilters { analysisFailed?: boolean; // 新增:分析是否失败 /** SPDX id 过滤;过滤面板可采用 `NO_LICENSE_SENTINEL` 表示「无/未声明 license」。 */ licenses: string[]; // 新增:开源许可过滤 + /** + * Repository Health 客观事实施加的筛选(见 `src/utils/repositoryHealth.ts`)。 + * 仅使用本地已存在的 `Repository` 字段,不触发额外网络请求; + * 依赖 Release 的事实(是否有 Release、最新版本)由 Health 面板与 MCP/AI 复用, + * 不进列表筛选——列表筛选器拿不到 Release 数组,硬塞会产生全量重渲染。 + */ + healthArchived?: boolean; + healthRecentActivity?: boolean; + healthHasLicense?: boolean; } export type CategoryMatchMode = 'legacy' | 'effective'; diff --git a/src/types/installableAsset.ts b/src/types/installableAsset.ts new file mode 100644 index 000000000..880cf05fe --- /dev/null +++ b/src/types/installableAsset.ts @@ -0,0 +1,77 @@ +/** + * Installable Asset Detection 模型。 + * + * 目标:统一回答「这个 Release 里哪一个资产可以在当前设备上安装」, + * 供 Release 视图、Discovery、My Apps、AI、MCP 与插件复用同一份结果。 + * + * 边界(roadmap §5.3): + * - 只做**识别**。不下载后执行、不自动选择来源不明的第三方镜像。 + * - 不因为扩展名像安装包就断言软件安全。 + * - 不确定时给出多个候选,而不是伪装成唯一正确答案。 + * - 用户始终可以手动选择其他 Release Asset。 + */ + +/** 当前支持识别的目标平台。 */ +export type InstallablePlatform = 'windows' | 'macos' | 'linux' | 'android'; + +/** 设备/资产的 CPU 架构;`universal` 表示单一产物覆盖多种架构。 */ +export type InstallableArchitecture = 'x64' | 'arm64' | 'x86' | 'universal'; + +/** 支持识别的安装包类型。 */ +export type InstallablePackageType = + | 'exe' + | 'msi' + | 'zip' + | '7z' + | 'dmg' + | 'pkg' + | 'deb' + | 'rpm' + | 'appimage' + | 'tar.gz' + | 'apk' + | 'aab'; + +/** + * 识别置信度。 + * - `high`:安装包类型与平台由决定性扩展名确定,且平台与目标平台一致。 + * - `medium`:安装包类型确定,平台靠文件名语义词推断,或架构无法确定。 + * - `low`:只能作为候选(例如通用 `.zip`、缺少平台标记的裸压缩包)。 + */ +export type InstallableConfidence = 'high' | 'medium' | 'low'; + +export interface InstallableAsset { + assetId: number; + fileName: string; + downloadUrl: string; + size: number; + + platform: InstallablePlatform; + architecture?: InstallableArchitecture; + packageType: InstallablePackageType; + + confidence: InstallableConfidence; + /** 人类可读的判定依据,用于向用户解释「为什么推荐这个」。 */ + reason: string; +} + +/** 识别结果:候选按置信度与资产顺序稳定排序。 */ +export interface InstallableAssetDetectionResult { + /** 与目标平台匹配的候选(可能多于一个——不确定时并列展示)。 */ + matches: InstallableAsset[]; + /** + * 被显式排除的资产及其原因(source code、checksum、signature、symbols、debug、 + * source archive、blockmap、非目标平台等)。用于回答「为什么某个资产没出现」。 + */ + excluded: Array<{ assetId: number; fileName: string; reason: string }>; +} + +/** 识别上下文。默认取当前设备,测试可显式注入。 */ +export interface InstallableAssetDetectionOptions { + /** 目标平台;省略时表示「不按平台过滤」,返回所有平台的候选。 */ + platform?: InstallablePlatform; + /** 目标架构;省略时不做架构过滤(架构信息仍会解析出来)。 */ + architecture?: InstallableArchitecture; + /** AAB 只识别、不作为可直接安装的推荐(Android)。 */ + includeDetectOnly?: boolean; +} diff --git a/src/types/repositoryImport.ts b/src/types/repositoryImport.ts new file mode 100644 index 000000000..4c25924eb --- /dev/null +++ b/src/types/repositoryImport.ts @@ -0,0 +1,130 @@ +/** + * Batch Repository Intake —— 批量导入的候选模型。 + * + * 处理流程(roadmap §6 / 开发守则 §6): + * ``` + * Paste → Extract → Normalize → Deduplicate → Resolve GitHub metadata + * → Enrich with local/Core data → Review → Batch actions + * ``` + * 本文件描述**前四步**的产物:一个已经归一化为 `owner/repo`、已去重、但仍未联网校验的候选。 + * 「Resolve GitHub metadata」(是否存在、是否私有、是否改名/转移、是否限流)与 + * 「Enrich with local/Core data」(是否已 Star、是否已在 My Apps)由后续阶段填充, + * 因此 `status` 与 `reason` 的取值域在这里一次性定义完整,避免下一阶段再改模型。 + */ +/** 候选来源。第一版只实现 `text` 与 `json`;`clipboard` / `file` 留给后续阶段复用同一模型。 */ +export type ImportSource = 'text' | 'json' | 'clipboard' | 'file'; + +/** + * 候选状态。 + * - `pending`:归一化成功,尚未联网校验(Extract/Deduplicate 阶段的正常结果)。 + * - `resolved`:已成功解析到 GitHub 元数据(Resolve 阶段填充)。 + * - `duplicate`:同一仓库(或同一无效片段)在本次输入中重复出现,本条是重复项。 + * - `invalid`:看起来像 GitHub 仓库但无法归一化(保留字路径、格式非法等),本阶段即可判定。 + * - `unavailable`:能归一化但解析失败(不存在 / 私有 / 改名 / 限流等),Resolve 阶段填充。 + */ +export type ImportCandidateStatus = + | 'pending' + | 'resolved' + | 'duplicate' + | 'invalid' + | 'unavailable'; + +/** 候选是怎么被识别出来的,供预览界面区分可信度。 */ +export type ImportCandidateMatchedBy = 'github-url' | 'bare-slug'; + +/** + * 识别可信度。 + * - `high`:来自明确包含 `github.com` 的 URL(含 release/issue/tree/blob 等子路径)。 + * - `low`:来自正文里的裸 `owner/repo` 写法。散文里这对词天然有歧义 + * (`src/utils`、`and/or`、`TCP/IP` 之类),因此一律降级,交由预览阶段由用户确认。 + */ +export type ImportCandidateConfidence = 'high' | 'low'; + +/** + * 失败原因。 + * + * 标注了阶段的取值由对应阶段产出,本阶段只产出 `not-a-repository-url` / `malformed-slug`: + * - Extract 阶段:`not-a-repository-url`、`malformed-slug` + * - Resolve 阶段:`not-found`、`private-or-inaccessible`、`renamed`、`rate-limited` + * - Enrich 阶段:`already-exists` + */ +export type ImportFailureReason = + | 'not-a-repository-url' + | 'malformed-slug' + | 'not-found' + | 'private-or-inaccessible' + | 'renamed' + | 'rate-limited' + | 'already-exists'; + +/** + * 一个导入候选。 + * + * `originalValue` 始终保留原始片段——批量导入的每一步都必须是可回溯的, + * 用户需要能看到「这条是从哪段文本里识别出来的」。 + */ +export interface ImportedRepositoryCandidate { + /** 归一化后的 `owner/repo`;`invalid` 时为空字符串。 */ + repositoryFullName: string; + source: ImportSource; + /** 输入中触发本条候选的原始片段(未修改)。 */ + originalValue: string; + status: ImportCandidateStatus; + /** 本地是否已 Star(Enrich 阶段填充;本阶段在调用方提供本地集合时也会填)。 */ + alreadyStarred?: boolean; + /** 仅 `invalid` / `unavailable` 有值。 */ + reason?: ImportFailureReason; + /** 识别来源,附加字段(守则模型之外),用于预览界面区分 URL 与裸写法。 */ + matchedBy?: ImportCandidateMatchedBy; + /** 识别可信度,附加字段(守则模型之外)。 */ + confidence?: ImportCandidateConfidence; + /** + * Resolve 阶段发现仓库被改名/转移时的原始 `owner/repo`。 + * 守则要求显示 `old-owner/repo → new-owner/repo` 且**不得静默修改**, + * 因此把旧名字单独留在这里,由用户确认后才替换。 + */ + previousFullName?: string; +} + +/** 输入层面的问题(与单个候选无关)。 */ +export interface RepositoryImportInputError { + /** + * `json-parse-failed` 与 `input-too-large` 是致命问题:此时 `candidates` 为空。 + * `too-many-values` 与 `depth-limit-exceeded` 只是截断:`candidates` 仍包含已扫描到的结果。 + */ + code: 'json-parse-failed' | 'input-too-large' | 'too-many-values' | 'depth-limit-exceeded'; + message: string; +} + +/** Extract + Normalize + Deduplicate 的结果。 */ +export interface RepositoryImportExtractionResult { + /** 按首次出现顺序排列的候选(含 `invalid` 与 `duplicate`)。 */ + candidates: ImportedRepositoryCandidate[]; + /** 输入整体不合法或被截断时的问题列表;致命问题(见 {@link RepositoryImportInputError})时 `candidates` 为空。 */ + inputErrors: RepositoryImportInputError[]; + stats: { + /** 实际扫描过的字符串数量(JSON 递归时是各层字符串值之和)。 */ + scanned: number; + /** 归一化成功的候选数(不含 duplicates / invalid)。 */ + valid: number; + duplicates: number; + invalid: number; + }; +} + +/** 提取选项。 */ +export interface RepositoryImportExtractionOptions { + /** 输入类型;`json` 会先做 JSON 解析再递归扫描字符串值。默认 `text`。 */ + source?: ImportSource; + /** + * 本地已有仓库的小写 `owner/repo` 集合;提供时用于填充 `alreadyStarred`。 + * 这是纯函数输入,不做任何 IO。 + */ + localRepositoryFullNames?: ReadonlySet<string>; + /** 输入最大长度(字符数),超出则报 `input-too-large` 并放弃扫描。默认 512 KiB。 */ + maxInputLength?: number; + /** 递归扫描 JSON 时最多处理多少个字符串值,防止病态输入。默认 20000。 */ + maxValues?: number; + /** JSON 递归最大深度。默认 32。 */ + maxDepth?: number; +} diff --git a/src/utils/deviceTarget.test.ts b/src/utils/deviceTarget.test.ts new file mode 100644 index 000000000..13dd55bb6 --- /dev/null +++ b/src/utils/deviceTarget.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + detectDevicePlatformSync, + resetDeviceTargetCacheForTests, + resolveDeviceArchitecture, +} from './deviceTarget'; + +/** 覆盖 navigator 上的只读属性(userAgentData / platform)。 */ +function stubNavigator(values: { platform?: string; userAgent?: string; userAgentData?: unknown }): void { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(navigator, key, { + value, + configurable: true, + writable: true, + }); + } +} + +afterEach(() => { + resetDeviceTargetCacheForTests(); + Reflect.deleteProperty(navigator as unknown as Record<string, unknown>, 'userAgentData'); + vi.restoreAllMocks(); +}); + +describe('detectDevicePlatformSync', () => { + it('prefers userAgentData.platform', () => { + stubNavigator({ userAgentData: { platform: 'Windows' }, platform: 'Linux x86_64' }); + expect(detectDevicePlatformSync()).toBe('windows'); + }); + + it('maps macOS and Android platforms', () => { + stubNavigator({ userAgentData: { platform: 'macOS' } }); + expect(detectDevicePlatformSync()).toBe('macos'); + + stubNavigator({ userAgentData: { platform: 'Android' } }); + expect(detectDevicePlatformSync()).toBe('android'); + }); + + it('falls back to navigator.platform', () => { + stubNavigator({ userAgentData: undefined, platform: 'Linux x86_64' }); + expect(detectDevicePlatformSync()).toBe('linux'); + + stubNavigator({ platform: 'MacIntel' }); + expect(detectDevicePlatformSync()).toBe('macos'); + }); + + it('falls back to the user agent string', () => { + stubNavigator({ platform: '', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Electron/41' }); + expect(detectDevicePlatformSync()).toBe('linux'); + }); + + it('returns null instead of guessing on an unrecognised platform', () => { + stubNavigator({ platform: 'SunOS', userAgent: 'Mozilla/5.0 (Unknown)' }); + expect(detectDevicePlatformSync()).toBeNull(); + }); +}); + +describe('resolveDeviceArchitecture', () => { + it('derives x64 from the high-entropy architecture and bitness hints', async () => { + const getHighEntropyValues = vi.fn().mockResolvedValue({ architecture: 'x86', bitness: '64' }); + stubNavigator({ userAgentData: { platform: 'Windows', getHighEntropyValues } }); + + await expect(resolveDeviceArchitecture()).resolves.toBe('x64'); + expect(getHighEntropyValues).toHaveBeenCalledWith(['architecture', 'bitness']); + }); + + it('derives arm64 and x86', async () => { + stubNavigator({ + userAgentData: { + getHighEntropyValues: vi.fn().mockResolvedValue({ architecture: 'arm', bitness: '64' }), + }, + }); + await expect(resolveDeviceArchitecture()).resolves.toBe('arm64'); + + resetDeviceTargetCacheForTests(); + stubNavigator({ + userAgentData: { + getHighEntropyValues: vi.fn().mockResolvedValue({ architecture: 'x86', bitness: '32' }), + }, + }); + await expect(resolveDeviceArchitecture()).resolves.toBe('x86'); + }); + + it('caches the result so the hint API is queried once', async () => { + const getHighEntropyValues = vi.fn().mockResolvedValue({ architecture: 'arm', bitness: '64' }); + stubNavigator({ userAgentData: { getHighEntropyValues } }); + + await resolveDeviceArchitecture(); + await resolveDeviceArchitecture(); + + expect(getHighEntropyValues).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when the hint API is unavailable', async () => { + stubNavigator({ userAgentData: { platform: 'Windows' } }); + await expect(resolveDeviceArchitecture()).resolves.toBeUndefined(); + }); + + it('returns undefined instead of failing when the hint call rejects', async () => { + stubNavigator({ + userAgentData: { + getHighEntropyValues: vi.fn().mockRejectedValue(new Error('not allowed')), + }, + }); + await expect(resolveDeviceArchitecture()).resolves.toBeUndefined(); + }); + + it('does not invent an architecture for 32-bit ARM', async () => { + stubNavigator({ + userAgentData: { + getHighEntropyValues: vi.fn().mockResolvedValue({ architecture: 'arm', bitness: '32' }), + }, + }); + await expect(resolveDeviceArchitecture()).resolves.toBeUndefined(); + }); +}); diff --git a/src/utils/deviceTarget.ts b/src/utils/deviceTarget.ts new file mode 100644 index 000000000..b0b44ef6d --- /dev/null +++ b/src/utils/deviceTarget.ts @@ -0,0 +1,114 @@ +/** + * 当前设备的平台与架构识别。 + * + * 用途:Installable Asset Detection 需要知道「这台设备是什么」,才能判断哪些 Release + * 资产可安装。识别结果**只作为推荐信号**,不是不可取消的硬过滤(roadmap §7)。 + * + * 为什么不用 Electron IPC 拿 `process.platform` / `process.arch`: + * - Web 版没有 Electron 进程,必须有一套浏览器可用的实现; + * - 新增一个只为读平台/架构的 IPC 会在 Host 侧扩大接口面,而 + * `navigator.userAgentData` 在 Electron 渲染进程里同样报告宿主 OS/CPU 架构。 + * 因此这里统一走 Web API,宿主插件运行时的 `hostEnvironment`(process.platform/arch) + * 保持不变、各管一摊。 + * + * 架构只能**尽力而为**:`navigator.userAgentData` 的架构属于 high-entropy hints, + * 只能异步获取。拿不到时返回 undefined,调用方应跳过架构过滤、并列展示候选, + * 而不是猜一个。 + */ +import type { InstallableArchitecture, InstallablePlatform } from '../types/installableAsset'; + +/** `navigator.userAgentData` 的最小类型(TS DOM lib 未内建 getHighEntropyValues)。 */ +interface UADataLike { + platform?: string; + getHighEntropyValues?: (hints: string[]) => Promise<{ + architecture?: string; + bitness?: string; + platform?: string; + }>; +} + +function readUserAgentData(): UADataLike | null { + if (typeof navigator === 'undefined') return null; + const data = (navigator as Navigator & { userAgentData?: UADataLike }).userAgentData; + return data ?? null; +} + +/** 把 `userAgentData.platform` / `navigator.platform` / UA 字符串归一化为目标平台。 */ +function normalizePlatformToken(value: string | undefined | null): InstallablePlatform | null { + if (!value) return null; + const token = value.toLowerCase(); + if (token.includes('android')) return 'android'; + if (token.includes('win')) return 'windows'; + if (token.includes('mac') || token.includes('darwin') || token.includes('iphone') || token.includes('ipad')) { + return 'macos'; + } + if (token.includes('linux') || token.includes('x11') || token.includes('crkey')) { + // CrOS 不支持本仓库的 Linux 安装包格式,但归入 Linux 比归入「未知」更少误导; + // 真正的兼容性判定发生在包类型层面(deb/rpm/AppImage)。 + return 'linux'; + } + return null; +} + +/** + * 同步识别当前设备平台。 + * 优先 `userAgentData.platform`(Chromium/Electron),回退 `navigator.platform`, + * 最后回退 UA 字符串。无法识别时返回 null,调用方必须按「不按平台过滤」处理。 + */ +export function detectDevicePlatformSync(): InstallablePlatform | null { + if (typeof navigator === 'undefined') return null; + const fromUAData = normalizePlatformToken(readUserAgentData()?.platform); + if (fromUAData) return fromUAData; + const fromPlatform = normalizePlatformToken(navigator.platform); + if (fromPlatform) return fromPlatform; + return normalizePlatformToken(navigator.userAgent); +} + +/** 把 high-entropy 的 `architecture` + `bitness` 组合归一化为目标架构。 */ +function normalizeArchitecture( + architecture: string | undefined, + bitness: string | undefined, +): InstallableArchitecture | undefined { + const arch = (architecture ?? '').toLowerCase(); + const bits = (bitness ?? '').toLowerCase(); + if (arch === 'x86') return bits === '64' ? 'x64' : bits === '32' ? 'x86' : undefined; + if (arch === 'arm') return bits === '64' ? 'arm64' : undefined; + if (arch === 'arm64' || arch === 'aarch64') return 'arm64'; + if (arch === 'x86_64' || arch === 'amd64' || arch === 'x64') return 'x64'; + return undefined; +} + +/** 进程内缓存:架构在一次会话里不会变,避免多次异步探测。 */ +let architectureCache: InstallableArchitecture | undefined; +let architectureProbe: Promise<InstallableArchitecture | undefined> | null = null; + +/** + * 尽力解析当前设备架构。 + * 拿不到 high-entropy hints 时返回 undefined(例如 Firefox/Safari,或 API 拒绝), + * 调用方应据此放弃架构过滤而不是假定 x64。 + */ +export function resolveDeviceArchitecture(): Promise<InstallableArchitecture | undefined> { + if (architectureCache !== undefined) return Promise.resolve(architectureCache); + if (architectureProbe) return architectureProbe; + const data = readUserAgentData(); + if (!data?.getHighEntropyValues) return Promise.resolve(undefined); + + architectureProbe = data + .getHighEntropyValues(['architecture', 'bitness']) + .then((values) => { + architectureCache = normalizeArchitecture(values?.architecture, values?.bitness); + return architectureCache; + }) + .catch(() => undefined) + .finally(() => { + architectureProbe = null; + }); + + return architectureProbe; +} + +/** 仅供测试:清空架构缓存。 */ +export function resetDeviceTargetCacheForTests(): void { + architectureCache = undefined; + architectureProbe = null; +} diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts new file mode 100644 index 000000000..1ceb3494e --- /dev/null +++ b/src/utils/formatBytes.ts @@ -0,0 +1,20 @@ +/** + * 字节数的展示格式化。 + * + * 从 `RepositoryReleaseSheet` 内的局部实现提取出来,供 Release 资产表与 + * 可安装资产推荐块共用同一套口径(同一份数据不应该在两个地方显示成不同大小)。 + */ + +/** + * 把字节数格式化为人类可读字符串。 + * + * @param bytes 字节数;`null` 返回破折号(表示未知,例如 Source code 条目)。 + * @returns 例如 `0 B`、`1.5 KB`、`12.0 MB`。 + */ +export function formatFileSize(bytes: number | null): string { + if (bytes === null) return '—'; + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} diff --git a/src/utils/installableAssets.test.ts b/src/utils/installableAssets.test.ts new file mode 100644 index 000000000..677c05fd7 --- /dev/null +++ b/src/utils/installableAssets.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest'; +import type { ReleaseAsset } from '../types'; +import { + detectInstallableAssets, + hasInstallableAsset, +} from './installableAssets'; + +function asset(id: number, name: string, contentType = 'application/octet-stream'): ReleaseAsset { + return { + id, + name, + size: 1024 * id, + download_count: 0, + browser_download_url: `https://github.com/acme/alpha/releases/download/v1/${name}`, + content_type: contentType, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }; +} + +const WINDOWS_DEVICE = { platform: 'windows', architecture: 'x64' } as const; + +describe('detectInstallableAssets — 平台与包类型', () => { + it('identifies a Windows installer with its architecture as high confidence', () => { + const result = detectInstallableAssets([asset(1, 'App-1.2.0-x64-setup.exe')], WINDOWS_DEVICE); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + assetId: 1, + platform: 'windows', + architecture: 'x64', + packageType: 'exe', + confidence: 'high', + }); + expect(result.matches[0].downloadUrl).toContain('App-1.2.0-x64-setup.exe'); + expect(result.matches[0].reason).toContain('Windows'); + }); + + it('downgrades confidence when the installer does not declare an architecture', () => { + // 决定性扩展名确定了平台,但没写架构 → 只能算 medium,不假装知道。 + const result = detectInstallableAssets([asset(1, 'App-1.2.0-setup.exe')], WINDOWS_DEVICE); + + expect(result.matches[0]).toMatchObject({ + platform: 'windows', + packageType: 'exe', + confidence: 'medium', + }); + expect(result.matches[0].architecture).toBeUndefined(); + expect(result.matches[0].reason).toContain('architecture not declared'); + }); + + it('recognises a macOS universal build', () => { + const result = detectInstallableAssets( + [asset(1, 'App-1.2.0-universal.dmg', 'application/x-apple-diskimage')], + { platform: 'macos', architecture: 'arm64' }, + ); + + expect(result.matches[0]).toMatchObject({ + platform: 'macos', + architecture: 'universal', + packageType: 'dmg', + confidence: 'high', + }); + }); + + it('recognises a Linux AppImage built for x86_64', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0-linux-x86_64.AppImage')], { + platform: 'linux', + architecture: 'x64', + }); + + expect(result.matches[0]).toMatchObject({ + platform: 'linux', + architecture: 'x64', + packageType: 'appimage', + confidence: 'high', + }); + }); + + it('recognises a Windows portable 7z by filename platform token', () => { + const result = detectInstallableAssets([asset(1, 'App_win64_portable.7z')], WINDOWS_DEVICE); + + expect(result.matches[0]).toMatchObject({ + platform: 'windows', + architecture: 'x64', + packageType: '7z', + confidence: 'medium', + }); + }); + + it('recognises an Android APK from the decisive extension', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0.apk')], { platform: 'android' }); + expect(result.matches[0]).toMatchObject({ platform: 'android', packageType: 'apk' }); + }); + + it('never treats win32 as a 32-bit architecture marker', () => { + // Electron 用 win32 命名 Windows 构建;它是平台名,不是 x86 标记。 + const result = detectInstallableAssets([asset(1, 'app-win32.zip')], WINDOWS_DEVICE); + expect(result.matches[0].architecture).toBeUndefined(); + }); +}); + +describe('detectInstallableAssets — 排除非安装资产', () => { + it.each([ + ['Source code (v1.2.0).zip', 'source code archive'], + ['app-1.2.0.exe.sha256', 'checksum file'], + ['app-1.2.0-setup.exe.sig', 'signature file'], + ['app-1.2.0.pdb', 'debug symbols'], + ['App-1.2.0-setup.exe.blockmap', 'electron blockmap'], + ['app-1.2.0.sbom.json', 'software bill of materials'], + ])('excludes %s', (name, expectedReason) => { + const result = detectInstallableAssets([asset(1, name)], WINDOWS_DEVICE); + + expect(result.matches).toEqual([]); + expect(result.excluded).toEqual([{ assetId: 1, fileName: name, reason: expectedReason }]); + }); + + it('does not treat a bare portable archive as installable software', () => { + const result = detectInstallableAssets([asset(1, 'myapp-1.0.zip')], WINDOWS_DEVICE); + + expect(result.matches).toEqual([]); + expect(result.excluded[0].reason).toBe('portable archive without a declared target platform'); + }); + + it('keeps unsupported package formats out of the candidates', () => { + const result = detectInstallableAssets( + [asset(1, 'app-1.0.pkg.tar.zst'), asset(2, 'app-1.0.nupkg')], + { platform: 'linux' }, + ); + + expect(result.matches).toEqual([]); + expect(result.excluded.map((entry) => entry.reason)).toEqual([ + 'not a supported installable package format', + 'not a supported installable package format', + ]); + }); + + it('refuses names that declare conflicting platforms', () => { + const result = detectInstallableAssets([asset(1, 'project-win32-linux-x64.zip')], WINDOWS_DEVICE); + + expect(result.matches).toEqual([]); + expect(result.excluded[0].reason).toContain('multiple platforms'); + }); + + it('refuses an asset whose extension contradicts its filename', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0-linux.dmg')], { platform: 'macos' }); + + expect(result.matches).toEqual([]); + expect(result.excluded[0].reason).toContain('package extension targets macos'); + }); + + it('excludes other-platform assets but says why', () => { + const result = detectInstallableAssets( + [asset(1, 'app-1.2.0.deb'), asset(2, 'App-1.2.0-setup.exe')], + WINDOWS_DEVICE, + ); + + expect(result.matches.map((match) => match.assetId)).toEqual([2]); + expect(result.excluded[0]).toMatchObject({ assetId: 1 }); + expect(result.excluded[0].reason).toBe('targets linux, this device is windows'); + }); + + it('excludes a conflicting architecture when the device architecture is known', () => { + const result = detectInstallableAssets( + [asset(1, 'App-1.2.0-arm64.dmg'), asset(2, 'App-1.2.0-x64.dmg'), asset(3, 'App-1.2.0.dmg')], + { platform: 'macos', architecture: 'x64' }, + ); + + expect(result.matches.map((match) => match.assetId)).toEqual([2, 3]); + expect(result.excluded[0]).toMatchObject({ assetId: 1 }); + expect(result.excluded[0].reason).toBe('targets arm64, this device is x64'); + }); + + it('keeps universal builds for every device architecture', () => { + const result = detectInstallableAssets( + [asset(1, 'App-1.2.0-universal.dmg')], + { platform: 'macos', architecture: 'x64' }, + ); + expect(result.matches).toHaveLength(1); + }); +}); + +describe('detectInstallableAssets — 不确定时并列候选', () => { + it('keeps every architecture when the device architecture is unknown', () => { + const result = detectInstallableAssets( + [asset(1, 'App-x64.dmg'), asset(2, 'App-arm64.dmg')], + { platform: 'macos' }, + ); + + expect(result.matches.map((match) => match.assetId)).toEqual([1, 2]); + expect(result.matches.every((match) => match.confidence === 'high')).toBe(true); + }); + + it('returns candidates for every platform when no target platform is given', () => { + const result = detectInstallableAssets([ + asset(1, 'App-setup.exe'), + asset(2, 'App-x86_64.AppImage'), + asset(3, 'App-universal.dmg'), + ]); + + expect(result.matches.map((match) => match.platform).sort()).toEqual([ + 'linux', + 'macos', + 'windows', + ]); + }); + + it('orders candidates by confidence, then architecture fit, then asset id', () => { + const result = detectInstallableAssets( + [ + asset(5, 'App-win64-portable.7z'), // medium(容器 + 已知架构 x64) + asset(2, 'App-arm64-setup.exe'), // 排除:架构冲突 + asset(3, 'App-setup.exe'), // medium(决定性扩展名,但未声明架构) + asset(1, 'App-x64-setup.exe'), // high + 精确架构 → 第一 + ], + WINDOWS_DEVICE, + ); + + // 同为 medium 时,声明了 x64 的 5 排在架构未知的 3 前面。 + expect(result.matches.map((match) => match.assetId)).toEqual([1, 5, 3]); + expect(result.matches.map((match) => match.confidence)).toEqual(['high', 'medium', 'medium']); + expect(result.excluded.map((entry) => entry.assetId)).toEqual([2]); + }); + + it('flags an asset that declares multiple architectures without guessing', () => { + const result = detectInstallableAssets([asset(1, 'App-x64-arm64-setup.exe')], { + platform: 'windows', + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0].architecture).toBeUndefined(); + expect(result.matches[0].confidence).toBe('medium'); + expect(result.matches[0].reason).toContain('multiple architectures'); + }); +}); + +describe('detectInstallableAssets — Android AAB 只识别', () => { + it('excludes AAB from installable candidates by default', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0.aab')], { platform: 'android' }); + + expect(result.matches).toEqual([]); + expect(result.excluded[0].reason).toContain('detect-only'); + }); + + it('recognises AAB when explicitly asked to include detect-only assets', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0.aab')], { + platform: 'android', + includeDetectOnly: true, + }); + + expect(result.matches[0]).toMatchObject({ platform: 'android', packageType: 'aab' }); + expect(result.matches[0].reason).toContain('detect only'); + }); +}); + +describe('detectInstallableAssets — 边界与非目标', () => { + it('never claims an asset is safe', () => { + const result = detectInstallableAssets([asset(1, 'App-x64-setup.exe')], WINDOWS_DEVICE); + expect(result.matches[0].reason.toLowerCase()).not.toContain('safe'); + }); + + it('ignores nameless assets instead of throwing', () => { + const nameless = { ...asset(1, 'App-x64-setup.exe'), name: '' }; + expect(detectInstallableAssets([nameless], WINDOWS_DEVICE).matches).toEqual([]); + }); + + it('handles missing asset lists', () => { + expect(detectInstallableAssets(undefined, WINDOWS_DEVICE)).toEqual({ matches: [], excluded: [] }); + }); + + it('falls back to the MIME type when the filename carries no platform signal', () => { + const result = detectInstallableAssets([asset(1, 'app-1.2.0.tar.gz', 'application/x-deb')], { + platform: 'linux', + }); + + expect(result.matches[0]).toMatchObject({ platform: 'linux', packageType: 'tar.gz' }); + }); +}); + +describe('hasInstallableAsset', () => { + it('answers the repository-level question without exposing candidates', () => { + expect(hasInstallableAsset([asset(1, 'App-x64-setup.exe')], WINDOWS_DEVICE)).toBe(true); + expect(hasInstallableAsset([asset(1, 'App-x64-setup.exe')], { platform: 'linux' })).toBe(false); + expect(hasInstallableAsset([], WINDOWS_DEVICE)).toBe(false); + }); +}); diff --git a/src/utils/installableAssets.ts b/src/utils/installableAssets.ts new file mode 100644 index 000000000..426cc922b --- /dev/null +++ b/src/utils/installableAssets.ts @@ -0,0 +1,333 @@ +/** + * Installable Asset Detection —— 判断一个 Release 里哪些资产可以在当前设备上安装。 + * + * 复用与新增的边界(先读代码再动手的结论): + * - **复用**:平台判定直接调用 [`detectAssetPlatform`](./releaseAssets.ts) 与它导出的 + * [`OS_TOKEN_PLATFORM`](./releaseAssets.ts),不另写一份平台词表。 + * - **新增**:架构词表、包类型表、排除规则与置信度模型。仓库里唯一存在的架构词表在 + * `examples/plugins/smart-release-recommender/worker.js`,那是沙箱内的第三方插件参考实现, + * `src/` 无法 import(插件也不能反向依赖 Core),因此这里必须有一份 Host 侧实现, + * 并在下方注明与那份参考实现的对应关系。 + * - **不复用** [`PRESET_FILTERS`](../constants/presetFilters.ts):它把 `zip` / `tar.gz` + * 归入 Source、且用朴素子串匹配(`win` 会命中 `darwin`),与「可安装软件识别」的目标冲突。 + * + * 边界(roadmap §5.3):只识别,不执行;不宣称安装包安全;不确定时并列多个候选; + * 用户始终可以在资产表里手动选择其他资产。 + */ +import type { ReleaseAsset } from '../types'; +import type { + InstallableArchitecture, + InstallableAsset, + InstallableAssetDetectionOptions, + InstallableAssetDetectionResult, + InstallableConfidence, + InstallablePackageType, + InstallablePlatform, +} from '../types/installableAsset'; +import { detectAssetPlatform, OS_TOKEN_PLATFORM, type AssetPlatform } from './releaseAssets'; + +/** 本阶段支持的目标平台;`ios` / `docker` 不属于「可安装到当前设备」的范畴。 */ +const INSTALLABLE_PLATFORMS: readonly InstallablePlatform[] = ['windows', 'macos', 'linux', 'android']; + +function toInstallablePlatform(platform: AssetPlatform | null): InstallablePlatform | null { + return platform && (INSTALLABLE_PLATFORMS as readonly string[]).includes(platform) + ? (platform as InstallablePlatform) + : null; +} + +/** + * 包类型表:后缀 → 包类型 + 该后缀是否**决定性**地确定了平台。 + * + * 按后缀长度降序匹配(`detectAssetPlatform` 的分层注释里说明了为什么必须先长后短)。 + * `decisivePlatform` 为 null 的容器格式(zip/7z/tar.gz)只说明「怎么装」, + * 不说明「装在哪」——平台交给文件名里的 OS 词元判定。 + */ +interface PackageTypeEntry { + suffix: string; + packageType: InstallablePackageType; + decisivePlatform: InstallablePlatform | null; + /** Android App Bundle 只能识别,不能当作可直接安装的推荐。 */ + detectOnly?: boolean; +} + +const PACKAGE_TYPE_ENTRIES: PackageTypeEntry[] = [ + { suffix: '.appimage', packageType: 'appimage', decisivePlatform: 'linux' }, + { suffix: '.tar.gz', packageType: 'tar.gz', decisivePlatform: null }, + { suffix: '.msi', packageType: 'msi', decisivePlatform: 'windows' }, + { suffix: '.exe', packageType: 'exe', decisivePlatform: 'windows' }, + { suffix: '.dmg', packageType: 'dmg', decisivePlatform: 'macos' }, + { suffix: '.pkg', packageType: 'pkg', decisivePlatform: 'macos' }, + { suffix: '.deb', packageType: 'deb', decisivePlatform: 'linux' }, + { suffix: '.rpm', packageType: 'rpm', decisivePlatform: 'linux' }, + { suffix: '.apk', packageType: 'apk', decisivePlatform: 'android' }, + { suffix: '.aab', packageType: 'aab', decisivePlatform: 'android', detectOnly: true }, + { suffix: '.zip', packageType: 'zip', decisivePlatform: null }, + { suffix: '.7z', packageType: '7z', decisivePlatform: null }, +]; + +/** 长后缀优先,避免 `.pkg` 抢在 `.tar.gz` 之类的前面。 */ +const PACKAGE_TYPES: PackageTypeEntry[] = [...PACKAGE_TYPE_ENTRIES].sort( + (left, right) => right.suffix.length - left.suffix.length, +); + +/** + * 排除规则:这些资产不是「可安装软件」,必须在候选之前剔除。 + * 规则集与 `examples/plugins/smart-release-recommender/worker.js` 的 + * `SOURCE_ARCHIVE` / checksum 正则保持同一意图,但覆盖范围更完整 + * (该参考实现只有 source / checksum / signature 三类)。 + */ +const EXCLUSION_RULES: ReadonlyArray<{ reason: string; pattern: RegExp }> = [ + { reason: 'source code archive', pattern: /(?:^|[^a-z0-9])(?:source|src)(?:[^a-z0-9]?(?:code|archive))?(?=$|[^a-z0-9])|源码/ }, + { reason: 'checksum file', pattern: /(?:checksum|sha256sum|sha512sum|\.sha256$|\.sha512$|\.md5$|(?:^|[^a-z0-9])md5(?=$|[^a-z0-9]))/ }, + { reason: 'signature file', pattern: /\.(?:sig|asc|minisig|p7s)$|(?:^|[^a-z0-9])sigstore(?=$|[^a-z0-9])/ }, + { reason: 'debug symbols', pattern: /\.(?:pdb|dsym)$|(?:^|[^a-z0-9])(?:symbols?|debug|dsym)(?=$|[^a-z0-9])/ }, + { reason: 'electron blockmap', pattern: /\.blockmap$/ }, + { reason: 'software bill of materials', pattern: /(?:^|[^a-z0-9])(?:sbom|spdx|cyclonedx)(?=$|[^a-z0-9])/ }, +]; + +/** + * 架构词表。 + * 与参考实现(worker.js L11-16)的差异有两点,都是刻意的: + * - 不使用裸 `x86` 之外还接受 `ia32` / `i386…i686`(Electron 的 32 位命名); + * `win32` 是 Windows 的平台名而不是 32 位标记,参考实现的注释也这么说,这里同样不当作架构。 + * - 增加 `universal`(macOS 通用二进制 / 多架构单一产物)。参考实现从不产出该值, + * 于是 `project-macos-universal.dmg` 只靠平台得分胜出;这里把它识别成一等架构。 + */ +const ARCH_RULES: ReadonlyArray<{ architecture: InstallableArchitecture; pattern: RegExp }> = [ + { architecture: 'universal', pattern: /(?:^|[^a-z0-9])(?:universal2?|multiarch)(?=$|[^a-z0-9])/ }, + { architecture: 'x64', pattern: /(?:^|[^a-z0-9])(?:x86[_-]?64|amd64|x64|win64)(?=$|[^a-z0-9])/ }, + { architecture: 'arm64', pattern: /(?:^|[^a-z0-9])(?:aarch64|arm64|arm64e)(?=$|[^a-z0-9])/ }, + { architecture: 'x86', pattern: /(?:^|[^a-z0-9])(?:ia32|i[3-6]86|x86(?![_-]?64))(?=$|[^a-z0-9])/ }, +]; + +/** 置信度排序用权重(高在前)。 */ +const CONFIDENCE_ORDER: Record<InstallableConfidence, number> = { high: 0, medium: 1, low: 2 }; + +/** 架构相对当前设备的贴合度:精确匹配最好,其次通用产物,最后是未知。 */ +function architectureRank( + architecture: InstallableArchitecture | undefined, + deviceArchitecture: InstallableArchitecture | undefined, +): number { + if (architecture === undefined) return 2; + if (architecture === 'universal') return 1; + return architecture === deviceArchitecture ? 0 : 1; +} + +/** 从文件名解析包类型(后缀匹配,长后缀优先)。 */ +function detectPackageType(fileName: string) { + const name = fileName.toLowerCase(); + return PACKAGE_TYPES.find((entry) => name.endsWith(entry.suffix)) ?? null; +} + +/** 文件名里声明的架构集合;空集表示没有声明。 */ +function declaredArchitectures(fileName: string): Set<InstallableArchitecture> { + const name = fileName.toLowerCase(); + const found = new Set<InstallableArchitecture>(); + for (const { architecture, pattern } of ARCH_RULES) { + if (pattern.test(name)) found.add(architecture); + } + return found; +} + +/** 文件名里声明的平台集合(复用 releaseAssets 的词表,不重复维护)。 */ +function declaredPlatforms(fileName: string): Set<InstallablePlatform> { + const found = new Set<InstallablePlatform>(); + for (const token of fileName.toLowerCase().split(/[^a-z0-9]+/)) { + const platform = toInstallablePlatform(OS_TOKEN_PLATFORM[token] ?? null); + if (platform) found.add(platform); + } + return found; +} + +/** + * 解析资产架构。 + * 返回 `ambiguous` 表示文件名同时声明了多个架构(如 `app-x64-arm64.zip`)—— + * 此时不猜,架构留空并降级置信度。 + */ +function resolveArchitecture(fileName: string): { + architecture?: InstallableArchitecture; + ambiguous: boolean; +} { + const declared = declaredArchitectures(fileName); + if (declared.size === 0) return { ambiguous: false }; + // 通用产物覆盖所有架构,与具体架构同时出现时以通用为准(不矛盾)。 + if (declared.has('universal')) return { architecture: 'universal', ambiguous: false }; + if (declared.size === 1) return { architecture: [...declared][0], ambiguous: false }; + return { ambiguous: true }; +} + +/** 组装人类可读的判定依据。UI 另有本地化标签,这里同时供 AI/MCP/插件解释「为什么」。 */ +function buildReason(input: { + platform: InstallablePlatform; + packageType: InstallablePackageType; + architecture?: InstallableArchitecture; + ambiguousArchitecture: boolean; + platformFromExtension: boolean; + detectOnly: boolean; +}): string { + const platformLabel = input.platform === 'macos' ? 'macOS' : input.platform[0].toUpperCase() + input.platform.slice(1); + const parts = [`${platformLabel} ${input.packageType} package`]; + if (input.architecture) parts.push(`declares ${input.architecture}`); + if (input.ambiguousArchitecture) parts.push('declares multiple architectures'); + if (!input.architecture && !input.ambiguousArchitecture) parts.push('architecture not declared'); + parts.push( + input.platformFromExtension + ? 'platform from package extension' + : 'platform inferred from filename', + ); + if (input.detectOnly) parts.push('detect only — not directly installable'); + return parts.join('; '); +} + +/** + * 识别一个 Release 的资产集合。 + * + * @param assets Release 资产(`Release.assets`)。 + * @param options 目标设备与过滤条件;省略 `platform` 表示「不按平台过滤」,返回全部候选。 + * @returns 候选(按置信度、架构贴合度、资产 id 稳定排序)与被排除资产及原因。 + */ +export function detectInstallableAssets( + assets: readonly ReleaseAsset[] | undefined, + options: InstallableAssetDetectionOptions = {}, +): InstallableAssetDetectionResult { + const matches: InstallableAsset[] = []; + const excluded: InstallableAssetDetectionResult['excluded'] = []; + const targetPlatform = options.platform; + const deviceArchitecture = options.architecture; + + for (const asset of assets ?? []) { + const fileName = asset?.name ?? ''; + if (!fileName) continue; + + const lowercase = fileName.toLowerCase(); + const exclusion = EXCLUSION_RULES.find((rule) => rule.pattern.test(lowercase)); + if (exclusion) { + excluded.push({ assetId: asset.id, fileName, reason: exclusion.reason }); + continue; + } + + const packageInfo = detectPackageType(fileName); + if (!packageInfo) { + excluded.push({ assetId: asset.id, fileName, reason: 'not a supported installable package format' }); + continue; + } + // AAB 只识别、不直接安装:默认不进候选(roadmap §5.1)。 + if (packageInfo.detectOnly && !options.includeDetectOnly) { + excluded.push({ assetId: asset.id, fileName, reason: 'Android App Bundle is detect-only and cannot be installed directly' }); + continue; + } + + const declared = declaredPlatforms(fileName); + if (declared.size > 1) { + excluded.push({ + assetId: asset.id, + fileName, + reason: `filename declares multiple platforms (${[...declared].join(', ')})`, + }); + continue; + } + + // 平台来源优先级:文件名唯一平台 → 决定性扩展名。两者冲突时宁可排除也不猜。 + const fromExtension = packageInfo.decisivePlatform; + let platform: InstallablePlatform | null = null; + let platformFromExtension = false; + if (declared.size === 1) { + const declaredPlatform = [...declared][0]; + if (fromExtension && fromExtension !== declaredPlatform) { + excluded.push({ + assetId: asset.id, + fileName, + reason: `package extension targets ${fromExtension} but the filename declares ${declaredPlatform}`, + }); + continue; + } + platform = declaredPlatform; + } else if (fromExtension) { + platform = fromExtension; + platformFromExtension = true; + } else { + // 容器本身不说明装在哪(例如 `myapp-1.0.zip`)。此时再复用既有 + // detectAssetPlatform 的最后一层——content_type 的 MIME 兜底—— + // 只有它还能提供平台证据;其余情形不猜(roadmap §5.3「不把 ZIP 一律当作可安装软件」), + // 资产表里仍可手动下载。 + const fromContentType = toInstallablePlatform( + detectAssetPlatform(fileName, asset.content_type), + ); + if (!fromContentType) { + excluded.push({ + assetId: asset.id, + fileName, + reason: 'portable archive without a declared target platform', + }); + continue; + } + platform = fromContentType; + } + + if (targetPlatform && platform !== targetPlatform) { + excluded.push({ + assetId: asset.id, + fileName, + reason: `targets ${platform}, this device is ${targetPlatform}`, + }); + continue; + } + + const { architecture, ambiguous } = resolveArchitecture(fileName); + if (deviceArchitecture && architecture && architecture !== 'universal' && architecture !== deviceArchitecture) { + excluded.push({ + assetId: asset.id, + fileName, + reason: `targets ${architecture}, this device is ${deviceArchitecture}`, + }); + continue; + } + + // 置信度:决定性扩展名 + 已知架构 = 高;容器格式或架构未知则降级。 + let confidence: InstallableConfidence; + if (fromExtension && architecture && !ambiguous) confidence = 'high'; + else if (fromExtension) confidence = 'medium'; + else if (architecture && !ambiguous) confidence = 'medium'; + else confidence = 'low'; + + matches.push({ + assetId: asset.id, + fileName, + downloadUrl: asset.browser_download_url, + size: asset.size, + platform, + architecture, + packageType: packageInfo.packageType, + confidence, + reason: buildReason({ + platform, + packageType: packageInfo.packageType, + architecture, + ambiguousArchitecture: ambiguous, + platformFromExtension, + detectOnly: packageInfo.detectOnly === true, + }), + }); + } + + matches.sort( + (left, right) => + CONFIDENCE_ORDER[left.confidence] - CONFIDENCE_ORDER[right.confidence] || + architectureRank(left.architecture, deviceArchitecture) - + architectureRank(right.architecture, deviceArchitecture) || + left.assetId - right.assetId, + ); + + return { matches, excluded }; +} + +/** + * 仓库级便捷判断:该 Release 资产集合里是否存在当前设备可安装的软件。 + * 供 Discovery 筛选、Repository Health 与批量导入预览复用。 + */ +export function hasInstallableAsset( + assets: readonly ReleaseAsset[] | undefined, + options: InstallableAssetDetectionOptions = {}, +): boolean { + return detectInstallableAssets(assets, options).matches.length > 0; +} diff --git a/src/utils/repoSearch.ts b/src/utils/repoSearch.ts index 9311c5ae0..72f883601 100644 --- a/src/utils/repoSearch.ts +++ b/src/utils/repoSearch.ts @@ -1,6 +1,11 @@ import type { Category, Repository, SearchFilters } from '../types'; import { isRepoCustomized } from './repoUtils'; import { normalizeLicense } from './licenseFilter'; +import { + hasDeclaredLicense, + hasRecentActivity, + isArchivedRepository, +} from './repositoryHealth'; /** Partial filters used by MCP and UI search (all fields optional except when provided). */ export type RepoSearchFilterInput = Partial<SearchFilters> & { @@ -78,6 +83,9 @@ function getSortValue(repo: Repository, sortBy: SearchFilters['sortBy']): number return repo.name.toLocaleLowerCase(); case 'starred': return toSortableTimestamp(repo.starred_at); + case 'created': + // “按创建时间排序”用于成熟度视角:越新创建的仓库排在越前(desc)。 + return toSortableTimestamp(repo.created_at); default: return toUpdatedSortValue(repo); } @@ -202,6 +210,24 @@ export function applyRepoFilters<T extends Repository>( filtered = filtered.filter((repo) => repo.stargazers_count <= searchFilters.maxStars!); } + // Repository Health 客观事实筛选。三态:undefined = 不筛选,true/false = 要求成立/不成立。 + // 判定逻辑集中在 src/utils/repositoryHealth.ts,避免与 Health 面板的口径分裂。 + if (searchFilters.healthArchived !== undefined) { + filtered = filtered.filter( + (repo) => isArchivedRepository(repo) === searchFilters.healthArchived, + ); + } + if (searchFilters.healthRecentActivity !== undefined) { + filtered = filtered.filter( + (repo) => hasRecentActivity(repo) === searchFilters.healthRecentActivity, + ); + } + if (searchFilters.healthHasLicense !== undefined) { + filtered = filtered.filter( + (repo) => hasDeclaredLicense(repo) === searchFilters.healthHasLicense, + ); + } + const sortBy = searchFilters.sortBy ?? 'stars'; const sortOrder = searchFilters.sortOrder ?? 'desc'; return sortRepositories(filtered, sortBy, sortOrder); @@ -225,6 +251,9 @@ export function hasActiveSearchFilters(filters: SearchFilters): boolean { filters.isEdited !== undefined || filters.isCategoryLocked !== undefined || filters.analysisFailed !== undefined || + filters.healthArchived !== undefined || + filters.healthRecentActivity !== undefined || + filters.healthHasLicense !== undefined || filters.sortBy !== 'stars' || filters.sortOrder !== 'desc' ); diff --git a/src/utils/repositoryHealth.test.ts b/src/utils/repositoryHealth.test.ts new file mode 100644 index 000000000..2e424dc21 --- /dev/null +++ b/src/utils/repositoryHealth.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect } from 'vitest'; +import type { Release, Repository } from '../types'; +import { + deriveRepositoryHealthSnapshot, + deriveRepositoryHealthSignals, + groupRepositoryHealthFacts, + hasDeclaredLicense, + hasRecentActivity, + isArchivedRepository, + isPrereleaseRelease, + NO_RECENT_ACTIVITY_DAYS, + releasesForRepository, + REPOSITORY_HEALTH_GROUP_ORDER, +} from './repositoryHealth'; + +const NOW = Date.parse('2026-09-17T00:00:00.000Z'); + +function makeRepo(overrides: Partial<Repository> = {}): Repository { + return { + id: 1, + name: 'alpha', + full_name: 'acme/alpha', + description: 'A test repository', + html_url: 'https://github.com/acme/alpha', + stargazers_count: 1500, + forks_count: 120, + forks: 120, + language: 'TypeScript', + created_at: '2020-09-17T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + pushed_at: '2026-09-01T00:00:00.000Z', + owner: { login: 'acme', avatar_url: '' }, + topics: [], + license: 'MIT', + ...overrides, + }; +} + +function makeRelease(overrides: Partial<Release> & Pick<Release, 'id' | 'tag_name' | 'published_at'>): Release { + return { + name: null, + body: null, + html_url: `https://github.com/acme/alpha/releases/tag/${overrides.tag_name}`, + assets: [], + repository: { id: 1, full_name: 'acme/alpha', name: 'alpha' }, + ...overrides, + }; +} + +describe('isPrereleaseRelease', () => { + it('trusts the GitHub prerelease flag', () => { + expect(isPrereleaseRelease({ prerelease: true, tag_name: 'v1.0.0' })).toBe(true); + }); + + it('falls back to tag tokens without matching unrelated words', () => { + expect(isPrereleaseRelease({ tag_name: 'v1.2.0-rc1' })).toBe(true); + expect(isPrereleaseRelease({ tag_name: 'v1.2.0-beta.2' })).toBe(true); + expect(isPrereleaseRelease({ tag_name: 'nightly-2026-01-01' })).toBe(true); + // 'presto' 只是普通单词,不能因为含有 'pre' 就被当作预发布 + expect(isPrereleaseRelease({ tag_name: 'presto-1.0.0' })).toBe(false); + expect(isPrereleaseRelease({ tag_name: 'v1.2.0' })).toBe(false); + }); +}); + +describe('releasesForRepository', () => { + it('keeps only the matching repository and sorts newest first', () => { + const releases = [ + makeRelease({ id: 1, tag_name: 'v1.0.0', published_at: '2025-01-01T00:00:00.000Z' }), + makeRelease({ id: 2, tag_name: 'v2.0.0', published_at: '2026-01-01T00:00:00.000Z' }), + makeRelease({ + id: 3, + tag_name: 'v9.0.0', + published_at: '2026-02-01T00:00:00.000Z', + repository: { id: 99, full_name: 'other/repo', name: 'repo' }, + }), + makeRelease({ id: 4, tag_name: 'broken', published_at: 'not-a-date' }), + ]; + + expect(releasesForRepository(releases, 1).map((release) => release.id)).toEqual([2, 1]); + }); +}); + +describe('deriveRepositoryHealthSnapshot', () => { + it('derives release facts, age and activity from local data only', () => { + const releases = [ + makeRelease({ id: 1, tag_name: 'v1.0.0', published_at: '2025-01-01T00:00:00.000Z' }), + makeRelease({ id: 2, tag_name: 'v1.1.0', published_at: '2026-01-01T00:00:00.000Z' }), + makeRelease({ id: 3, tag_name: 'v2.0.0-rc1', published_at: '2026-06-01T00:00:00.000Z' }), + ]; + + const snapshot = deriveRepositoryHealthSnapshot( + makeRepo({ has_fetched_releases: true }), + releases, + undefined, + NOW, + ); + + expect(snapshot.releaseCount).toBe(3); + expect(snapshot.hasReleases).toBe(true); + expect(snapshot.latestReleaseAt).toBe('2026-06-01T00:00:00.000Z'); + expect(snapshot.latestStableVersion).toBe('v1.1.0'); + expect(snapshot.latestPrereleaseVersion).toBe('v2.0.0-rc1'); + // 仓库年龄恰好 6 年(含闰年共 2191 天)→ 3 个 release ≈ 0.5 次/年 + expect(snapshot.ageDays).toBe(2191); + expect(snapshot.releasesPerYear).toBe(0.5); + expect(snapshot.daysSinceLastPush).toBe(16); + expect(snapshot.stars).toBe(1500); + expect(snapshot.forks).toBe(120); + expect(snapshot.license).toBe('MIT'); + }); + + it('marks enrichment-backed facts as unknown when no enrichment is supplied', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo(), [], undefined, NOW); + + expect(snapshot.contributors).toBeUndefined(); + expect(snapshot.closedIssues).toBeUndefined(); + expect(snapshot.latestCommitAt).toBeUndefined(); + expect(snapshot.recentCommitCount).toBeUndefined(); + expect(snapshot.hasSecurityPolicy).toBeUndefined(); + expect(snapshot.hasCI).toBeUndefined(); + expect(snapshot.hasReadme).toBeUndefined(); + expect(snapshot.hasDocs).toBeUndefined(); + }); + + it('prefers forks_count and falls back to the legacy forks field', () => { + expect(deriveRepositoryHealthSnapshot(makeRepo({ forks_count: 7 }), [], undefined, NOW).forks).toBe(7); + expect( + deriveRepositoryHealthSnapshot( + makeRepo({ forks_count: undefined as unknown as number, forks: 3 }), + [], + undefined, + NOW, + ).forks, + ).toBe(3); + }); + + it('keeps release frequency unknown instead of reporting zero when nothing is known', () => { + // 既没有可解析的创建时间、也没有 Release 数据:只能承认「不知道」。 + const unknown = deriveRepositoryHealthSnapshot( + makeRepo({ created_at: 'not-a-date' }), + undefined, + undefined, + NOW, + ); + expect(unknown.releasesPerYear).toBeNull(); + expect(unknown.ageDays).toBeNull(); + + // 给了 Release 数组(哪怕是空数组)就说明调用方知道 Release 情况,此时 0 才是事实。 + const knownEmpty = deriveRepositoryHealthSnapshot( + makeRepo({ created_at: 'not-a-date' }), + [], + undefined, + NOW, + ); + expect(knownEmpty.releasesPerYear).toBe(0); + }); + + it('falls back to updated_at when pushed_at is unusable, like the MCP mirrors', () => { + const snapshot = deriveRepositoryHealthSnapshot( + makeRepo({ pushed_at: 'not-a-date', updated_at: '2026-09-10T00:00:00.000Z' }), + [], + undefined, + NOW, + ); + expect(snapshot.daysSinceLastPush).toBe(7); + }); + + it('never emits a numeric health score', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo(), [], undefined, NOW); + expect(snapshot).not.toHaveProperty('score'); + expect(snapshot).not.toHaveProperty('healthScore'); + }); +}); + +describe('conservative health signals', () => { + it('reports archived / disabled as objective status', () => { + const snapshot = deriveRepositoryHealthSnapshot( + makeRepo({ archived: true, disabled: true }), + [], + undefined, + NOW, + ); + expect(snapshot.signals.map((signal) => signal.id)).toEqual(['archived', 'disabled']); + }); + + it('only reports no-releases once releases were actually synced', () => { + const neverSynced = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: false }), [], undefined, NOW); + expect(neverSynced.signals.map((signal) => signal.id)).not.toContain('no-releases'); + + const syncedEmpty = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: true }), [], undefined, NOW); + expect(syncedEmpty.signals.map((signal) => signal.id)).toContain('no-releases'); + }); + + it('does not claim release facts when the caller has no release data at all', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: true }), undefined, undefined, NOW); + expect(snapshot.releasesFetched).toBe(false); + expect(snapshot.signals.map((signal) => signal.id)).not.toContain('no-releases'); + }); + + it('flags stale pushes neutrally, without calling a mature project unhealthy', () => { + const mature = makeRepo({ pushed_at: '2024-01-01T00:00:00.000Z' }); + const snapshot = deriveRepositoryHealthSnapshot(mature, [], undefined, NOW); + const signal = snapshot.signals.find((item) => item.id === 'no-recent-activity'); + + expect(signal).toBeDefined(); + // 观测值就是最后一次 push 时间,供 UI 原样展示,不含任何判定 + expect(signal?.since).toBe(Date.parse('2024-01-01T00:00:00.000Z')); + expect(snapshot.daysSinceLastPush).toBeGreaterThan(NO_RECENT_ACTIVITY_DAYS); + // 成熟项目仍然保留完整的客观事实 + expect(snapshot.stars).toBe(1500); + expect(snapshot.license).toBe('MIT'); + }); + + it('does not flag repositories pushed within the threshold', () => { + const recent = makeRepo({ pushed_at: '2026-08-01T00:00:00.000Z' }); + expect( + deriveRepositoryHealthSnapshot(recent, [], undefined, NOW).signals.map((s) => s.id), + ).not.toContain('no-recent-activity'); + }); + + it('keeps signal order stable', () => { + const snapshot = deriveRepositoryHealthSnapshot( + makeRepo({ archived: true, disabled: true, pushed_at: '2020-01-01T00:00:00.000Z', has_fetched_releases: true }), + [], + undefined, + NOW, + ); + expect(deriveRepositoryHealthSignals(snapshot).map((signal) => signal.id)).toEqual([ + 'archived', + 'disabled', + 'no-releases', + 'no-recent-activity', + ]); + }); +}); + +describe('groupRepositoryHealthFacts', () => { + it('groups every fact in the fixed Activity/Maintenance/Community/Maturity order', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: true }), [], undefined, NOW); + const views = groupRepositoryHealthFacts(snapshot); + + expect(views.map((view) => view.group)).toEqual([...REPOSITORY_HEALTH_GROUP_ORDER]); + expect(views.flatMap((view) => view.facts).length).toBeGreaterThan(20); + }); + + it('distinguishes unknown facts from known-empty facts', () => { + const noLicense = deriveRepositoryHealthSnapshot(makeRepo({ license: null }), [], undefined, NOW); + const licenseFact = groupRepositoryHealthFacts(noLicense) + .flatMap((view) => view.facts) + .find((fact) => fact.id === 'license'); + // 已知且为空 → null + expect(licenseFact?.value).toBeNull(); + + const unknownContributors = groupRepositoryHealthFacts(noLicense) + .flatMap((view) => view.facts) + .find((fact) => fact.id === 'contributors'); + // 尚未获得 → undefined + expect(unknownContributors?.value).toBeUndefined(); + }); + + it('treats NOASSERTION as no declared license', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo({ license: 'NOASSERTION' }), [], undefined, NOW); + const licenseFact = groupRepositoryHealthFacts(snapshot) + .flatMap((view) => view.facts) + .find((fact) => fact.id === 'license'); + expect(licenseFact?.value).toBeNull(); + }); + + it('reports hasReleases as unknown until releases were synced', () => { + const unsynced = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: false }), [], undefined, NOW); + const fact = groupRepositoryHealthFacts(unsynced) + .flatMap((view) => view.facts) + .find((item) => item.id === 'hasReleases'); + expect(fact?.value).toBeUndefined(); + + const synced = deriveRepositoryHealthSnapshot(makeRepo({ has_fetched_releases: true }), [], undefined, NOW); + const syncedFact = groupRepositoryHealthFacts(synced) + .flatMap((view) => view.facts) + .find((item) => item.id === 'hasReleases'); + expect(syncedFact?.value).toBe(false); + }); + + it('marks enrichment-backed facts with their source so the UI can label them', () => { + const snapshot = deriveRepositoryHealthSnapshot(makeRepo(), [], undefined, NOW); + const contributors = groupRepositoryHealthFacts(snapshot) + .flatMap((view) => view.facts) + .find((fact) => fact.id === 'contributors'); + expect(contributors?.source).toBe('enrichment'); + }); +}); + +describe('list filter predicates', () => { + it('treats missing archived as not archived', () => { + expect(isArchivedRepository(makeRepo())).toBe(false); + expect(isArchivedRepository(makeRepo({ archived: true }))).toBe(true); + }); + + it('falls back to updated_at for recent activity', () => { + const repo = makeRepo({ pushed_at: 'not-a-date', updated_at: '2026-09-10T00:00:00.000Z' }); + expect(hasRecentActivity(repo, NOW)).toBe(true); + }); + + it('treats unparsable activity timestamps as not recently active', () => { + expect(hasRecentActivity(makeRepo({ pushed_at: '', updated_at: '' }), NOW)).toBe(false); + }); + + it('reuses the shared license normalization', () => { + expect(hasDeclaredLicense(makeRepo({ license: 'Apache-2.0' }))).toBe(true); + expect(hasDeclaredLicense(makeRepo({ license: null }))).toBe(false); + expect(hasDeclaredLicense(makeRepo({ license: 'Other' }))).toBe(false); + }); +}); diff --git a/src/utils/repositoryHealth.ts b/src/utils/repositoryHealth.ts new file mode 100644 index 000000000..01e9d4cab --- /dev/null +++ b/src/utils/repositoryHealth.ts @@ -0,0 +1,393 @@ +/** + * Repository Health Core —— 从本地数据推导客观健康事实。 + * + * 纯函数、无副作用、无网络请求:输入 `Repository` + 本地 `Release[]`(+ 可选 enrichment), + * 输出 {@link RepositoryHealthSnapshot}。因此 Repository 列表、筛选、排序、Discovery、 + * AI 提示、MCP 证据与插件快照可以复用同一份结果,并且离线可用。 + * + * 明确的非目标: + * - 不输出 0–100 健康总分,不输出「健康 / 不健康」结论(主观评分属于插件)。 + * - 不因为「最近提交少」而把成熟稳定项目标记为异常:`no-recent-activity` 只是中性观测。 + * - 不猜测缺失事实:需要联网补全的字段在未补全时为 `undefined`(未知)。 + */ +import type { Release, Repository } from '../types'; +import type { + RepositoryHealthEnrichment, + RepositoryHealthFact, + RepositoryHealthFactId, + RepositoryHealthGroup, + RepositoryHealthGroupView, + RepositoryHealthSignal, + RepositoryHealthSnapshot, +} from '../types/health'; +import { NO_LICENSE_SENTINEL, normalizeLicense } from './licenseFilter'; + +const MS_PER_DAY = 86_400_000; +const DAYS_PER_YEAR = 365.25; + +/** + * 「近期无提交」观测阈值(天)。 + * 一年只是**展示**阈值:超过后 UI 显示中性文案「No pushes in the last 12 months」, + * 而不是判定项目不健康(成熟稳定项目长期不更新属正常状态)。 + */ +export const NO_RECENT_ACTIVITY_DAYS = 365; + +/** 发布频率的分母下限(月),避免新仓库出现「每年 365 个 release」这类噪声值。 */ +const MIN_FREQUENCY_WINDOW_DAYS = 30; + +/** tag 中的一个 token 命中即视为预发布。按 token 比对,避免 `presto` 里的 `pre` 之类误判。 */ +const PRERELEASE_TOKENS = new Set([ + 'alpha', + 'beta', + 'rc', + 'pre', + 'prerelease', + 'preview', + 'dev', + 'devel', + 'next', + 'canary', + 'snapshot', + 'nightly', + 'insider', + 'unstable', +]); + +/** 把时间字符串解析为 epoch 毫秒;缺失或不可解析返回 null。 */ +function toTimestamp(value?: string | null): number | null { + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + +/** 把可能为 undefined/NaN 的计数收敛为非负整数。 */ +function toCount(value: unknown): number { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; +} + +/** 保留一位小数,消除浮点尾差。 */ +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +/** + * 判断单个 Release 是否为预发布。 + * 先信任 GitHub 的 `prerelease` 标记,再按 tag 词元兜底(许多维护者只用 tag 表达预发布)。 + */ +export function isPrereleaseRelease(release: Pick<Release, 'prerelease' | 'tag_name'>): boolean { + if (release.prerelease === true) return true; + const tag = (release.tag_name ?? '').toLowerCase(); + if (!tag) return false; + return tag + .split(/[^a-z0-9]+/) + .filter(Boolean) + .some((token) => PRERELEASE_TOKENS.has(token.replace(/\d+$/, ''))); +} + +/** + * 取某个仓库的 Release,按发布时间降序(不可解析的条目丢弃)。 + * 只统计 `repository.id` 匹配的条目——调用方常传入全局 Release 数组。 + */ +export function releasesForRepository<T extends Pick<Release, 'repository' | 'published_at'>>( + releases: readonly T[] | undefined, + repositoryId: number, +): T[] { + return (releases ?? []) + .filter((release) => Number(release.repository?.id) === Number(repositoryId)) + .filter((release) => toTimestamp(release.published_at) !== null) + .slice() + .sort( + (left, right) => + (toTimestamp(right.published_at) as number) - (toTimestamp(left.published_at) as number), + ); +} + +/** + * 推导仓库健康事实快照。 + * + * @param repository 仓库实体(含 GitHub 原生状态字段与本地 AI/自定义字段)。 + * @param releases 本地已同步的 Release;可为全局数组,内部按 `repository.id` 过滤。 + * @param enrichment 可选联网补全结果;缺失字段在快照中保持 `undefined`(未知)。 + * @param now 计算「距今多久」的基准时间,便于测试注入。 + */ +export function deriveRepositoryHealthSnapshot( + repository: Repository, + releases?: readonly Release[], + enrichment?: RepositoryHealthEnrichment, + now: number = Date.now(), +): RepositoryHealthSnapshot { + const ownReleases = releasesForRepository(releases, repository.id); + const releaseCount = ownReleases.length; + const latestRelease = ownReleases[0] ?? null; + const latestStable = + ownReleases.find((release) => !isPrereleaseRelease(release)) ?? null; + const latestPrerelease = + ownReleases.find((release) => isPrereleaseRelease(release)) ?? null; + + const createdTimestamp = toTimestamp(repository.created_at); + // 与 `hasRecentActivity` 及 electron/server 两份镜像一致:pushed_at 缺失或不可解析时 + // 回落到 updated_at,避免同一份数据在「最近活动」筛选与事实面板上给出不同答案。 + const pushedTimestamp = + toTimestamp(repository.pushed_at) ?? toTimestamp(repository.updated_at); + const ageDays = + createdTimestamp === null + ? null + : Math.max(0, Math.floor((now - createdTimestamp) / MS_PER_DAY)); + const daysSinceLastPush = + pushedTimestamp === null + ? null + : Math.max(0, Math.floor((now - pushedTimestamp) / MS_PER_DAY)); + + // 发布频率:以仓库年龄为窗口,分母下限一个月,避免新仓库出现噪声极值。 + // 仓库年龄未知且调用方没给 Release 数据时保持 null(未知),不能报成 0 次/年 + // ——那会把「不知道」说成「从不发布」。 + const releasesPerYear = + ageDays === null + ? releases === undefined + ? null + : round1(releaseCount) + : round1( + releaseCount / + (Math.max(ageDays, MIN_FREQUENCY_WINDOW_DAYS) / DAYS_PER_YEAR), + ); + + const forkCount = + repository.forks_count !== undefined + ? toCount(repository.forks_count) + : toCount(repository.forks); + + const snapshot: RepositoryHealthSnapshot = { + archived: repository.archived === true, + disabled: repository.disabled, + fork: repository.fork === true, + isTemplate: repository.is_template === true, + + createdAt: repository.created_at ?? '', + pushedAt: repository.pushed_at || null, + latestCommitAt: enrichment?.latestCommitAt, + recentCommitCount: enrichment?.recentCommitCount, + + hasReleases: releaseCount > 0, + // 只有调用方显式传入 Release 数组(哪怕是空数组)时才承认「已知该仓库的 Release 情况」; + // 完全不传表示调用方没有这项数据,此时 Release 相关事实保持「未知」。 + releasesFetched: releases !== undefined && repository.has_fetched_releases === true, + latestReleaseAt: latestRelease?.published_at ?? null, + releaseCount, + releasesPerYear, + latestStableVersion: latestStable?.tag_name ?? null, + latestPrereleaseVersion: latestPrerelease?.tag_name ?? null, + + stars: toCount(repository.stargazers_count), + forks: forkCount, + openIssues: + repository.open_issues_count === undefined + ? undefined + : toCount(repository.open_issues_count), + closedIssues: enrichment?.closedIssues, + contributors: enrichment?.contributors, + + license: repository.license, + hasSecurityPolicy: enrichment?.hasSecurityPolicy, + hasCI: enrichment?.hasCI, + hasReadme: enrichment?.hasReadme, + hasDocs: enrichment?.hasDocs, + + ageDays, + daysSinceLastPush, + signals: [], + }; + + snapshot.signals = deriveRepositoryHealthSignals(snapshot); + return snapshot; +} + +/** + * 推导 Core 允许提供的保守状态。 + * + * 只有 4 种观测,且顺序固定(archived → disabled → no-releases → no-recent-activity): + * 顺序稳定可让 UI 与测试依赖它。`no-releases` 只在确认同步过 Release 后给出, + * 否则「本地没有 Release」只代表尚未拉取。`since` 只在时间型观测上有值。 + */ +export function deriveRepositoryHealthSignals( + snapshot: RepositoryHealthSnapshot, +): RepositoryHealthSignal[] { + const signals: RepositoryHealthSignal[] = []; + + if (snapshot.archived) { + signals.push({ id: 'archived', since: null, detail: snapshot.pushedAt }); + } + if (snapshot.disabled === true) { + signals.push({ id: 'disabled', since: null, detail: null }); + } + if (snapshot.releasesFetched && snapshot.releaseCount === 0) { + signals.push({ id: 'no-releases', since: null, detail: null }); + } + if ( + snapshot.daysSinceLastPush !== null && + snapshot.daysSinceLastPush >= NO_RECENT_ACTIVITY_DAYS + ) { + signals.push({ + id: 'no-recent-activity', + since: toTimestamp(snapshot.pushedAt), + detail: snapshot.pushedAt, + }); + } + + return signals; +} + +/** 分组顺序固定,UI 与测试依赖它。 */ +export const REPOSITORY_HEALTH_GROUP_ORDER: readonly RepositoryHealthGroup[] = [ + 'activity', + 'maintenance', + 'community', + 'maturity', +]; + +/** 每个分组包含的事实 id 与顺序(固定)。 */ +const GROUP_FACT_IDS: Record<RepositoryHealthGroup, readonly RepositoryHealthFactId[]> = { + activity: ['pushedAt', 'latestCommitAt', 'recentCommitCount', 'hasReleases', 'latestReleaseAt'], + maintenance: [ + 'archived', + 'disabled', + 'fork', + 'template', + 'license', + 'hasSecurityPolicy', + 'hasCI', + 'hasReadme', + 'hasDocs', + ], + community: ['stars', 'forks', 'openIssues', 'closedIssues', 'contributors'], + maturity: ['createdAt', 'ageDays', 'releaseCount', 'releasesPerYear', 'latestStableVersion'], +}; + +/** 每个事实的取值来源,供 UI 标注「需要联网补全」与筛选能力判断。 */ +const FACT_SOURCE: Record<RepositoryHealthFactId, RepositoryHealthFact['source']> = { + pushedAt: 'repository', + latestCommitAt: 'enrichment', + recentCommitCount: 'enrichment', + hasReleases: 'releases', + latestReleaseAt: 'releases', + archived: 'repository', + disabled: 'repository', + fork: 'repository', + template: 'repository', + license: 'repository', + hasSecurityPolicy: 'enrichment', + hasCI: 'enrichment', + hasReadme: 'enrichment', + hasDocs: 'enrichment', + stars: 'repository', + forks: 'repository', + openIssues: 'repository', + closedIssues: 'enrichment', + contributors: 'enrichment', + createdAt: 'repository', + ageDays: 'repository', + releaseCount: 'releases', + releasesPerYear: 'releases', + latestStableVersion: 'releases', +}; + +/** 每个事实的数据类型,供 UI 选择格式化方式。 */ +const FACT_KIND: Record<RepositoryHealthFactId, RepositoryHealthFact['kind']> = { + pushedAt: 'date', + latestCommitAt: 'date', + recentCommitCount: 'count', + hasReleases: 'boolean', + latestReleaseAt: 'date', + archived: 'boolean', + disabled: 'boolean', + fork: 'boolean', + template: 'boolean', + license: 'text', + hasSecurityPolicy: 'boolean', + hasCI: 'boolean', + hasReadme: 'boolean', + hasDocs: 'boolean', + stars: 'count', + forks: 'count', + openIssues: 'count', + closedIssues: 'count', + contributors: 'count', + createdAt: 'date', + ageDays: 'duration', + releaseCount: 'count', + releasesPerYear: 'count', + latestStableVersion: 'text', +}; + +/** + * 从快照取单个事实的原始值。 + * + * 注意三态语义:`undefined` = 未知,`null` = 已知且为空(例如无 license / 无稳定版本)。 + */ +function readFactValue( + snapshot: RepositoryHealthSnapshot, + id: RepositoryHealthFactId, +): RepositoryHealthFact['value'] { + switch (id) { + case 'hasReleases': + // 尚未同步过 Release 时,「没有 Release」并不成立——保持未知。 + if (!snapshot.releasesFetched && !snapshot.hasReleases) return undefined; + return snapshot.hasReleases; + case 'license': + // 空值统一收敛为「无 license」哨兵,避免 UI 把 '' 当成已声明。 + if (snapshot.license === undefined) return undefined; + return normalizeLicense(snapshot.license) === NO_LICENSE_SENTINEL + ? null + : snapshot.license; + case 'template': + // 事实 id 用 `template`(GitHub 语义),快照字段用 `isTemplate`(避免与 JS 保留语义混淆)。 + return snapshot.isTemplate; + default: + return snapshot[id] as RepositoryHealthFact['value']; + } +} + +/** + * 把快照展开为 UI 分组视图(Activity / Maintenance / Community / Maturity)。 + * 不做任何格式化——标签、日期与数字格式由 UI 层决定(i18n 重构后归口语言包)。 + */ +export function groupRepositoryHealthFacts( + snapshot: RepositoryHealthSnapshot, +): RepositoryHealthGroupView[] { + return REPOSITORY_HEALTH_GROUP_ORDER.map((group) => ({ + group, + facts: GROUP_FACT_IDS[group].map((id) => ({ + id, + group, + kind: FACT_KIND[id], + value: readFactValue(snapshot, id), + source: FACT_SOURCE[id], + })), + })); +} + +/** + * 判断仓库是否已归档。 + * 供列表筛选复用:`archived` 缺失(旧持久化数据 / 非 starred 来源)按未归档处理。 + */ +export function isArchivedRepository(repository: Pick<Repository, 'archived'>): boolean { + return repository.archived === true; +} + +/** + * 判断仓库最近是否有 push 活动。 + * `now` 可注入以便测试;时间不可解析时返回 false(筛选语义:未知不算「近期活跃」)。 + */ +export function hasRecentActivity( + repository: Pick<Repository, 'pushed_at' | 'updated_at'>, + now: number = Date.now(), +): boolean { + const pushed = toTimestamp(repository.pushed_at) ?? toTimestamp(repository.updated_at); + if (pushed === null) return false; + return now - pushed < NO_RECENT_ACTIVITY_DAYS * MS_PER_DAY; +} + +/** 判断仓库是否声明了可识别的 license(复用既有归一化,保证与 license 过滤器一致)。 */ +export function hasDeclaredLicense(repository: Pick<Repository, 'license'>): boolean { + return normalizeLicense(repository.license) !== NO_LICENSE_SENTINEL; +} diff --git a/src/utils/repositoryImport.test.ts b/src/utils/repositoryImport.test.ts new file mode 100644 index 000000000..25d39a1a9 --- /dev/null +++ b/src/utils/repositoryImport.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect } from 'vitest'; +import { + extractRepositoryCandidates, + isPlausibleBareSlug, + normalizeRepositoryFullName, + resolveRepositoryFromGitHubUrl, + toLocalRepositoryNameSet, +} from './repositoryImport'; + +/** 便捷断言:取候选的 `repositoryFullName` 列表。 */ +const names = (input: string, source: 'text' | 'json' = 'text') => + extractRepositoryCandidates(input, { source }).candidates.map((c) => c.repositoryFullName); + +describe('normalizeRepositoryFullName', () => { + it('accepts GitHub-legal owner/repo pairs', () => { + expect(normalizeRepositoryFullName('deskflow', 'deskflow')).toBe('deskflow/deskflow'); + expect(normalizeRepositoryFullName('My-Org', 'my_repo.js')).toBe('My-Org/my_repo.js'); + expect(normalizeRepositoryFullName('a1', 'b2')).toBe('a1/b2'); + }); + + it('strips a trailing .git suffix', () => { + expect(normalizeRepositoryFullName('foo', 'bar.git')).toBe('foo/bar'); + expect(normalizeRepositoryFullName('foo', 'bar.GIT')).toBe('foo/bar'); + }); + + it('rejects illegal names', () => { + expect(normalizeRepositoryFullName('-bad', 'repo')).toBeNull(); + expect(normalizeRepositoryFullName('own er', 'repo')).toBeNull(); + expect(normalizeRepositoryFullName('owner', '..')).toBeNull(); + expect(normalizeRepositoryFullName('owner', '.')).toBeNull(); + expect(normalizeRepositoryFullName('owner', 'has space')).toBeNull(); + expect(normalizeRepositoryFullName('owner', 'a'.repeat(101))).toBeNull(); + }); +}); + +describe('resolveRepositoryFromGitHubUrl', () => { + it.each([ + ['deskflow/deskflow', 'deskflow/deskflow'], + ['deskflow/deskflow/', 'deskflow/deskflow'], + ['deskflow/deskflow.git', 'deskflow/deskflow'], + ['deskflow/deskflow/releases/tag/v1.2.0', 'deskflow/deskflow'], + ['deskflow/deskflow/releases', 'deskflow/deskflow'], + ['deskflow/deskflow/releases/download/v1/app.exe', 'deskflow/deskflow'], + ['deskflow/deskflow/issues/12', 'deskflow/deskflow'], + ['deskflow/deskflow/pull/34', 'deskflow/deskflow'], + ['deskflow/deskflow/pulls', 'deskflow/deskflow'], + ['deskflow/deskflow/tree/main/src', 'deskflow/deskflow'], + ['deskflow/deskflow/blob/main/README.md', 'deskflow/deskflow'], + ['deskflow/deskflow/actions', 'deskflow/deskflow'], + ['deskflow/deskflow/wiki', 'deskflow/deskflow'], + ['deskflow/deskflow/discussions/5', 'deskflow/deskflow'], + ['deskflow/deskflow/commit/abc123', 'deskflow/deskflow'], + ['deskflow/deskflow/compare/a...b', 'deskflow/deskflow'], + ['deskflow/deskflow/stargazers', 'deskflow/deskflow'], + ['deskflow/deskflow/graphs/commit-activity', 'deskflow/deskflow'], + ['deskflow/deskflow/security/advisories', 'deskflow/deskflow'], + ['deskflow/deskflow/packages/1', 'deskflow/deskflow'], + ['deskflow/deskflow#readme', 'deskflow/deskflow'], + ['deskflow/deskflow?tab=readme-ov-file', 'deskflow/deskflow'], + ])('normalizes %s to its owning repository', (path, expected) => { + expect(resolveRepositoryFromGitHubUrl(path)).toEqual({ repositoryFullName: expected }); + }); + + it.each([ + ['orgs/foo/repositories', 'not-a-repository-url'], + ['topics/react', 'not-a-repository-url'], + ['settings/profile', 'not-a-repository-url'], + ['features/actions', 'not-a-repository-url'], + ['sponsors/someone', 'not-a-repository-url'], + ['marketplace/actions/checkout', 'not-a-repository-url'], + ['apps/dependabot', 'not-a-repository-url'], + ['trending', 'not-a-repository-url'], + ['', 'not-a-repository-url'], + ['-bad-owner/repo', 'malformed-slug'], + ['owner/..', 'malformed-slug'], + ])('reports %s as unusable (%s)', (path, reason) => { + expect(resolveRepositoryFromGitHubUrl(path)).toEqual({ reason }); + }); +}); + +describe('extractRepositoryCandidates — GitHub URL 形态', () => { + it('handles scheme, www, bare host, trailing slash and .git', () => { + const input = [ + 'https://github.com/deskflow/deskflow', + 'github.com/CyrilPeng/FlowScroll', + 'http://www.github.com/foo/bar/', + 'https://github.com/acme/app.git', + ].join('\n'); + + expect(names(input)).toEqual([ + 'deskflow/deskflow', + 'CyrilPeng/FlowScroll', + 'foo/bar', + 'acme/app', + ]); + }); + + it('normalizes release / issue / PR / tree / blob links to the owning repository', () => { + const input = [ + 'https://github.com/foo/bar/releases/tag/v1.2.0', + 'https://github.com/foo/bar/issues/7', + 'https://github.com/foo/bar/pull/9', + 'https://github.com/foo/bar/tree/main/src', + 'https://github.com/foo/bar/blob/main/index.ts', + ].join('\n'); + + // 五条都指向同一个仓库:首条 pending,其余为 duplicate + expect(names(input)).toEqual(['foo/bar', 'foo/bar', 'foo/bar', 'foo/bar', 'foo/bar']); + const result = extractRepositoryCandidates(input); + expect(result.stats).toEqual({ scanned: 1, valid: 1, duplicates: 4, invalid: 0 }); + }); + + it('finds URLs inside Markdown links, brackets, angle brackets and code spans', () => { + const input = [ + '[deskflow](https://github.com/deskflow/deskflow)', + '(https://github.com/foo/bar)', + '<https://github.com/acme/app>', + '`https://github.com/org/tool`', + ].join('\n'); + + expect(names(input)).toEqual(['deskflow/deskflow', 'foo/bar', 'acme/app', 'org/tool']); + }); + + it('ignores sentence punctuation after a URL', () => { + expect(names('See https://github.com/foo/bar.')).toEqual(['foo/bar']); + expect(names('Is it https://github.com/foo/bar?')).toEqual(['foo/bar']); + expect(names('Yes: https://github.com/foo/bar, and more')).toEqual(['foo/bar']); + }); + + it('keeps the raw fragment in originalValue for traceability', () => { + const [candidate] = extractRepositoryCandidates('see https://github.com/foo/bar.').candidates; + expect(candidate.originalValue).toBe('https://github.com/foo/bar.'); + expect(candidate.matchedBy).toBe('github-url'); + expect(candidate.confidence).toBe('high'); + }); + + it('marks site功能 paths as invalid with a reason instead of dropping them', () => { + const result = extractRepositoryCandidates( + 'https://github.com/orgs/foo/repositories\nhttps://github.com/topics/react', + ); + + expect(result.candidates).toEqual([ + expect.objectContaining({ + repositoryFullName: '', + status: 'invalid', + reason: 'not-a-repository-url', + matchedBy: 'github-url', + }), + expect.objectContaining({ status: 'invalid', reason: 'not-a-repository-url' }), + ]); + expect(result.stats.invalid).toBe(2); + }); +}); + +describe('extractRepositoryCandidates — 裸 owner/repo', () => { + it('accepts plausible bare slugs and marks them low confidence', () => { + const result = extractRepositoryCandidates('facebook/react and deskflow/deskflow'); + + expect(result.candidates.map((c) => c.repositoryFullName)).toEqual([ + 'facebook/react', + 'deskflow/deskflow', + ]); + expect(result.candidates.every((c) => c.matchedBy === 'bare-slug')).toBe(true); + expect(result.candidates.every((c) => c.confidence === 'low')).toBe(true); + }); + + it.each([ + ['src/utils', '代码目录名'], + ['docs/plans', '代码目录名'], + ['src/utils.ts', '文件扩展名'], + ['docs/guide.md', '文件扩展名'], + ['and/or', '常见词组'], + ['TCP/IP', '常见词组'], + ['read/write', '常见词组'], + ['topics/react', '站点保留字'], + ['24/7', '单字符段'], + ['i/o', '单字符段'], + ])('rejects the false positive %s (%s)', (slug) => { + expect(extractRepositoryCandidates(`see ${slug} for details`).candidates).toEqual([]); + }); + + it('does not treat part of a longer path as a slug', () => { + expect(extractRepositoryCandidates('path/to/thing').candidates).toEqual([]); + }); + + it('does not re-scan URL path segments as bare slugs', () => { + // 若不做 URL 屏蔽,`releases/tag` 与 `tag/v1.2.0` 会被误当成仓库 + const result = extractRepositoryCandidates('https://github.com/foo/bar/releases/tag/v1.2.0'); + expect(result.candidates.map((c) => c.matchedBy)).toEqual(['github-url']); + }); + + it('accepts a bare slug with a .git suffix', () => { + expect(names('clone acme/app.git now')).toEqual(['acme/app']); + }); + + it('exposes the bare-slug heuristic for reuse', () => { + expect(isPlausibleBareSlug('deskflow', 'deskflow')).toBe(true); + expect(isPlausibleBareSlug('src', 'utils')).toBe(false); + }); +}); + +describe('extractRepositoryCandidates — 去重', () => { + it('keeps the first occurrence as pending and marks the rest as duplicate', () => { + const input = [ + 'https://github.com/foo/bar', + 'foo/bar', + 'https://github.com/FOO/BAR/releases', + ].join('\n'); + + const result = extractRepositoryCandidates(input); + expect(result.candidates.map((c) => c.status)).toEqual(['pending', 'duplicate', 'duplicate']); + // 保留首次出现的大小写 + expect(result.candidates[0].repositoryFullName).toBe('foo/bar'); + expect(result.stats).toEqual({ scanned: 1, valid: 1, duplicates: 2, invalid: 0 }); + }); + + it('deduplicates repeated invalid fragments too', () => { + const result = extractRepositoryCandidates( + 'https://github.com/topics/a\nhttps://github.com/topics/a', + ); + expect(result.candidates.map((c) => c.status)).toEqual(['invalid', 'duplicate']); + }); +}); + +describe('extractRepositoryCandidates — JSON', () => { + it('scans string values recursively without requiring a schema', () => { + const input = JSON.stringify([ + 'https://github.com/foo/bar', + { repo: 'owner/repo', note: '值得看看' }, + { nested: { deeper: ['acme/tool'] } }, + ]); + + expect(names(input, 'json')).toEqual(['foo/bar', 'owner/repo', 'acme/tool']); + }); + + it('ignores non-string values and object keys', () => { + const input = JSON.stringify({ 'acme/key': 1, flag: true, count: 3, nothing: null }); + expect(names(input, 'json')).toEqual([]); + }); + + it('reports unparsable JSON without producing candidates', () => { + const result = extractRepositoryCandidates('{ not json', { source: 'json' }); + expect(result.candidates).toEqual([]); + expect(result.inputErrors[0].code).toBe('json-parse-failed'); + }); + + it('stops at the value limit but keeps what it already found', () => { + const result = extractRepositoryCandidates(JSON.stringify(['acme/one', 'acme/two']), { + source: 'json', + maxValues: 1, + }); + + expect(result.candidates.map((c) => c.repositoryFullName)).toEqual(['acme/one']); + expect(result.inputErrors[0].code).toBe('too-many-values'); + }); + + it('reports nesting beyond the depth limit', () => { + const result = extractRepositoryCandidates(JSON.stringify({ a: { b: { c: 'acme/deep' } } }), { + source: 'json', + maxDepth: 1, + }); + + expect(result.candidates).toEqual([]); + expect(result.inputErrors[0].code).toBe('depth-limit-exceeded'); + }); + + it('propagates the source onto every candidate', () => { + const result = extractRepositoryCandidates(JSON.stringify(['acme/one']), { source: 'json' }); + expect(result.candidates[0].source).toBe('json'); + }); +}); + +describe('extractRepositoryCandidates — 边界与本地数据', () => { + it('returns nothing for empty input', () => { + expect(extractRepositoryCandidates('')).toEqual({ + candidates: [], + inputErrors: [], + stats: { scanned: 1, valid: 0, duplicates: 0, invalid: 0 }, + }); + }); + + it('rejects oversized input before scanning', () => { + const result = extractRepositoryCandidates('https://github.com/foo/bar', { maxInputLength: 10 }); + expect(result.candidates).toEqual([]); + expect(result.inputErrors[0].code).toBe('input-too-large'); + expect(result.stats.scanned).toBe(0); + }); + + it('flags candidates that are already in the local library', () => { + const result = extractRepositoryCandidates('https://github.com/foo/bar\nacme/tool', { + localRepositoryFullNames: new Set(['foo/bar']), + }); + + expect(result.candidates[0].alreadyStarred).toBe(true); + expect(result.candidates[1].alreadyStarred).toBeUndefined(); + }); + + it('builds a lowercase local name set from repositories', () => { + expect(toLocalRepositoryNameSet([{ full_name: 'Foo/Bar' }])).toEqual(new Set(['foo/bar'])); + expect(toLocalRepositoryNameSet(undefined)).toEqual(new Set()); + }); + + it('handles the documented example end to end', () => { + const input = `https://github.com/deskflow/deskflow +github.com/CyrilPeng/FlowScroll +owner/repo +https://github.com/foo/bar/releases/tag/v1.2.0`; + + expect(names(input)).toEqual([ + 'deskflow/deskflow', + 'CyrilPeng/FlowScroll', + 'owner/repo', + 'foo/bar', + ]); + }); +}); diff --git a/src/utils/repositoryImport.ts b/src/utils/repositoryImport.ts new file mode 100644 index 000000000..30d01605f --- /dev/null +++ b/src/utils/repositoryImport.ts @@ -0,0 +1,396 @@ +/** + * Batch Repository Intake —— 提取与归一化。 + * + * 纯函数、无 IO:把粘贴进来的**普通文本 / Markdown / JSON** 归一化为 `owner/repo` 候选, + * 去重后按首次出现顺序返回。联网校验(仓库是否存在、是否私有、是否改名)属于下一阶段。 + * + * 归一化覆盖的 GitHub URL 形态(子路径一律丢弃,只留所属仓库): + * ``` + * https://github.com/owner/repo github.com/owner/repo + * https://www.github.com/owner/repo/ https://github.com/owner/repo.git + * https://github.com/owner/repo/releases/tag/v1.2.0 + * https://github.com/owner/repo/issues/12 /pull/34 /tree/main/src /blob/main/a.ts + * https://github.com/owner/repo/actions|wiki|discussions|commit|compare|stargazers|… + * ``` + * 以及正文里的裸 `owner/repo`。 + * + * 刻意不做的事: + * - 不联网、不猜仓库的真实大小写(GitHub 大小写不敏感,真实大小写由 Resolve 阶段覆盖)。 + * - 不把 `github.com/orgs/…`、`github.com/topics/…`、`github.com/settings/…` 等站点功能路径 + * 当成仓库,而是显式标记 `invalid` 并给出原因,让用户看得见而不是被静默丢弃。 + * - 不对裸 `owner/repo` 假装有把握:散文里 `A/B` 与仓库 slug 天然同形, + * 因此一律标记 `confidence: 'low'`,由预览阶段交给用户确认。 + */ +import type { Repository } from '../types'; +import type { + ImportCandidateConfidence, + ImportCandidateMatchedBy, + ImportFailureReason, + ImportedRepositoryCandidate, + ImportSource, + RepositoryImportExtractionOptions, + RepositoryImportExtractionResult, +} from '../types/repositoryImport'; + +const DEFAULT_MAX_INPUT_LENGTH = 512 * 1024; +const DEFAULT_MAX_VALUES = 20_000; +const DEFAULT_MAX_DEPTH = 32; + +/** GitHub 用户名:字母数字与连字符,且不能以连字符开头,最长 39 字符。 */ +const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +/** GitHub 仓库名:字母数字、`-`、`_`、`.`,最长 100 字符。 */ +const REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/; + +/** + * `github.com/<第一段>` 属于站点功能入口而非用户名的保留字。 + * 这些路径下的第二段(`orgs/foo`、`topics/react`)不是仓库,裸写法里同样要拒绝。 + */ +const RESERVED_ROOTS = new Set([ + 'about', 'account', 'apps', 'blog', 'business', 'careers', 'codespaces', 'collections', + 'contact', 'customer-stories', 'dashboard', 'developer', 'donate', 'education', 'enterprise', + 'events', 'explore', 'features', 'git', 'home', 'issues', 'join', 'login', 'logout', + 'marketplace', 'mobile', 'new', 'notifications', 'organizations', 'orgs', 'pages', 'partners', + 'pricing', 'projects', 'pulls', 'readme', 'search', 'security', 'settings', 'site', 'sponsors', + 'stars', 'topics', 'trending', 'users', 'wiki', 'sitemap', +]); + +/** + * 裸 `owner/repo` 的误报抑制之一:常见的**代码目录名**。 + * 本仓库自己的文档里就充满 `src/utils`、`docs/plans` 这类片段,不排除会大量误报。 + */ +const CODE_LIKE_DIRECTORIES = new Set([ + 'src', 'app', 'lib', 'libs', 'test', 'tests', 'docs', 'doc', 'dist', 'build', 'out', + 'public', 'assets', 'static', 'config', 'configs', 'scripts', 'script', 'node_modules', + 'server', 'client', 'packages', 'package', 'components', 'component', 'hooks', 'hook', + 'utils', 'util', 'pages', 'api', 'apis', 'routes', 'router', 'store', 'stores', + 'styles', 'types', 'features', 'feature', 'examples', 'example', 'fixtures', 'mocks', + 'templates', 'template', 'locales', 'i18n', 'electron', 'cloudflare-worker', + 'versions', 'bin', 'cmd', 'internal', 'pkg', 'vendor', 'third_party', +]); + +/** 裸 `owner/repo` 的误报抑制之二:常见文件扩展名(`src/utils.ts`、`docs/guide.md`)。 */ +const FILE_EXTENSIONS = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'json', 'jsonc', 'md', 'mdx', 'txt', 'yml', 'yaml', + 'toml', 'ini', 'cfg', 'conf', 'lock', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'svg', + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'py', 'rb', 'php', 'go', 'rs', 'java', 'kt', + 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'sql', + 'env', 'log', 'map', 'wasm', +]); + +/** + * 裸 `owner/repo` 的误报抑制之三:常见英文/技术词组。 + * + * 这是一个**必然不完整**的启发式——`and/or`、`24/7`、`TCP/IP` 与仓库 slug 同形, + * 无法靠词表穷尽。因此裸写法一律降级为 `confidence: 'low'`, + * 最终由预览阶段的用户确认(这正是「Review」环节存在的理由)。 + */ +const BARE_SLUG_STOPWORDS = new Set([ + 'and', 'or', 'either', 'neither', 'both', 'not', 'with', 'without', 'per', 'vs', 'versus', + 'he', 'she', 'it', 'they', 'we', 'you', 'his', 'her', 'its', 'their', 'our', 'your', + 'tcp', 'udp', 'ip', 'http', 'https', 'ftp', 'ssh', 'dns', 'ssl', 'tls', + 'read', 'write', 'readonly', 'readwrite', 'input', 'output', 'request', 'response', + 'client', 'server', 'frontend', 'backend', 'parent', 'child', 'master', 'slave', 'primary', + 'replica', 'source', 'target', 'left', 'right', 'up', 'down', 'in', 'out', 'on', 'off', + 'before', 'after', 'over', 'under', 'plus', 'minus', 'min', 'max', 'start', 'end', 'open', + 'close', 'true', 'false', 'yes', 'no', 'null', 'none', 'all', 'any', 'km', 'miles', + 'day', 'week', 'month', 'year', 'cost', 'benefit', 'pros', 'cons', 'win', 'loss', +]); + +/** 匹配 `github.com/…`,scheme 与 `www.` 均可省略。 */ +const GITHUB_URL_PATTERN = /(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s<>()[\]{}"'`]*)/gi; + +/** + * 匹配正文里的裸 `owner/repo`。 + * 前后用否定环视排除「更长路径的一部分」:`a/b/c` 里 `a/b` 与 `b/c` 都不会命中。 + */ +const BARE_SLUG_PATTERN = + /(?<![A-Za-z0-9._/-])([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100})(?![A-Za-z0-9_/-])/g; + +/** URL 尾部常见的标点/包裹字符(`owner/repo.`、`owner/repo,`)。括号与引号已被捕获组排除。 */ +function trimUrlTail(value: string): string { + return value.replace(/[.,;:!?]+$/, ''); +} + +/** 取扩展名(小写);没有扩展名返回空串。`.git` 视为后缀而非扩展名,单独处理。 */ +function extensionOf(segment: string): string { + const index = segment.lastIndexOf('.'); + if (index <= 0 || index === segment.length - 1) return ''; + return segment.slice(index + 1).toLowerCase(); +} + +/** + * 校验并归一化 `owner/repo`。 + * + * @param owner 用户名段。 + * @param repo 仓库名段(可带 `.git` 后缀,会被去掉)。 + * @returns 归一化后的 `owner/repo`(保留原大小写),或 null 表示不合法。 + */ +export function normalizeRepositoryFullName(owner: string, repo: string): string | null { + const cleanRepo = repo.toLowerCase().endsWith('.git') ? repo.slice(0, -4) : repo; + if (!OWNER_PATTERN.test(owner)) return null; + if (!REPO_PATTERN.test(cleanRepo)) return null; + // 纯点号(`.`、`..`)不是合法仓库名 + if (/^\.+$/.test(cleanRepo)) return null; + return `${owner}/${cleanRepo}`; +} + +/** + * 从 GitHub URL 的路径部分解析所属仓库(release / issue / PR / tree / blob 等子路径一律丢弃)。 + * + * @param url `github.com/` 之后的完整片段,可带 query / fragment / 尾部标点。 + * @returns 归一化结果,或失败原因。 + */ +export function resolveRepositoryFromGitHubUrl( + url: string, +): { repositoryFullName: string } | { reason: ImportFailureReason } { + const withoutQuery = url.split(/[?#]/)[0]; + + // 尾部标点通常是句子标点(`…/repo.`),但也可能属于路径本身(`owner/..` 会被整段吃掉)。 + // 因此先按「去尾部标点」解析;只有当段数因此不足两段时,才用未去标点的原串重试。 + for (const candidate of [trimUrlTail(withoutQuery), withoutQuery]) { + const segments = candidate.split('/').filter(Boolean); + if (segments.length === 0) continue; + + // 第一段是站点保留字 → 功能页,不是仓库 + if (RESERVED_ROOTS.has(segments[0].toLowerCase())) { + return { reason: 'not-a-repository-url' }; + } + // `github.com/<owner>` 是用户/组织主页,没有仓库名;换用未去标点的原串再试一次 + if (segments.length < 2) continue; + + const fullName = normalizeRepositoryFullName(segments[0], segments[1]); + return fullName ? { repositoryFullName: fullName } : { reason: 'malformed-slug' }; + } + + return { reason: 'not-a-repository-url' }; +} + +/** + * 判断裸 `owner/repo` 是否值得作为候选。 + * 命中任一抑制规则即拒绝——宁可漏报,也不要往预览里塞噪声。 + */ +export function isPlausibleBareSlug(owner: string, repo: string): boolean { + if (!OWNER_PATTERN.test(owner) || !REPO_PATTERN.test(repo)) return false; + if (/^\.+$/.test(repo)) return false; + // 单字符段在散文里几乎必然是 `I/O`、`N/A` 这类噪声,不是仓库 + if (owner.length < 2 || repo.length < 2) return false; + + const ownerLower = owner.toLowerCase(); + const repoLower = repo.toLowerCase(); + if (RESERVED_ROOTS.has(ownerLower)) return false; + if (BARE_SLUG_STOPWORDS.has(ownerLower) || BARE_SLUG_STOPWORDS.has(repoLower)) return false; + if (CODE_LIKE_DIRECTORIES.has(ownerLower) || CODE_LIKE_DIRECTORIES.has(repoLower)) return false; + + const extension = extensionOf(repo); + if (extension && extension !== 'git' && FILE_EXTENSIONS.has(extension)) return false; + return true; +} + +/** 把输入里的 GitHub URL 片段替换为等长空白,避免其路径段被裸 slug 规则二次命中。 */ +function maskGitHubUrls(text: string): string { + return text.replace(GITHUB_URL_PATTERN, (match) => ' '.repeat(match.length)); +} + +/** 递归收集 JSON 里的字符串值(只取值,不取键名)。返回收集是否被上限截断。 */ +function collectJsonStrings( + value: unknown, + out: string[], + limit: number, + depth: number, + maxDepth: number, +): 'ok' | 'value-limit' | 'depth-limit' { + if (typeof value === 'string') { + if (out.length >= limit) return 'value-limit'; + out.push(value); + return 'ok'; + } + if (value === null || typeof value !== 'object') return 'ok'; + if (depth >= maxDepth) return 'depth-limit'; + + const children = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>); + let result: 'ok' | 'value-limit' | 'depth-limit' = 'ok'; + for (const child of children) { + const childResult = collectJsonStrings(child, out, limit, depth + 1, maxDepth); + if (childResult === 'value-limit') return 'value-limit'; + if (childResult === 'depth-limit') result = 'depth-limit'; + } + return result; +} + +/** 去重键:合法的按仓库名(大小写不敏感),非法的按原始片段,避免同一片段重复报错。 */ +function dedupeKey(candidate: ImportedRepositoryCandidate): string { + return candidate.repositoryFullName + ? candidate.repositoryFullName.toLowerCase() + : `invalid:${candidate.originalValue.trim().toLowerCase()}`; +} + +/** + * 从粘贴内容提取仓库候选。 + * + * @param raw 原始输入(文本 / Markdown / JSON 字符串)。 + * @param options 提取选项;`source: 'json'` 时先解析 JSON 再递归扫描字符串值。 + */ +export function extractRepositoryCandidates( + raw: string, + options: RepositoryImportExtractionOptions = {}, +): RepositoryImportExtractionResult { + const source: ImportSource = options.source ?? 'text'; + const maxInputLength = options.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH; + const maxValues = options.maxValues ?? DEFAULT_MAX_VALUES; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const local = options.localRepositoryFullNames; + + if (raw.length > maxInputLength) { + return { + candidates: [], + inputErrors: [ + { + code: 'input-too-large', + message: `Input exceeds the ${maxInputLength} character limit`, + }, + ], + stats: { scanned: 0, valid: 0, duplicates: 0, invalid: 0 }, + }; + } + + const inputErrors: RepositoryImportExtractionResult['inputErrors'] = []; + // 第一版只实现 text / json;clipboard 与 file 复用同一套提取逻辑。 + let strings: string[]; + if (source === 'json') { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + candidates: [], + inputErrors: [ + { + code: 'json-parse-failed', + message: error instanceof Error ? error.message : 'Invalid JSON', + }, + ], + stats: { scanned: 0, valid: 0, duplicates: 0, invalid: 0 }, + }; + } + strings = []; + const collected = collectJsonStrings(parsed, strings, maxValues, 0, maxDepth); + if (collected === 'value-limit') { + inputErrors.push({ + code: 'too-many-values', + message: `JSON contains more than ${maxValues} string values; extraction stopped early`, + }); + } else if (collected === 'depth-limit') { + inputErrors.push({ + code: 'depth-limit-exceeded', + message: `JSON nesting exceeds ${maxDepth} levels; deeper values were skipped`, + }); + } + } else { + strings = [raw]; + } + + const candidates: ImportedRepositoryCandidate[] = []; + const seen = new Map<string, ImportedRepositoryCandidate>(); + + /** 记录候选;同一键重复出现时追加 `duplicate`,并保持首次出现的顺序。 */ + const push = ( + repositoryFullName: string, + originalValue: string, + status: ImportedRepositoryCandidate['status'], + extra: { + reason?: ImportFailureReason; + matchedBy?: ImportCandidateMatchedBy; + confidence?: ImportCandidateConfidence; + } = {}, + ) => { + const draft: ImportedRepositoryCandidate = { + repositoryFullName, + source, + originalValue, + status, + ...extra, + }; + if (seen.has(dedupeKey(draft))) { + candidates.push({ ...draft, status: 'duplicate' }); + return; + } + if (status === 'pending' && local?.has(repositoryFullName.toLowerCase())) { + draft.alreadyStarred = true; + } + seen.set(dedupeKey(draft), draft); + candidates.push(draft); + }; + + for (const text of strings) { + // 两类匹配要按**输入位置**合并排序后再去重:先扫完所有 URL 再扫裸写法会让 + // `owner/repo` 这类靠前的片段被排到后面,预览列表的顺序将不再反映用户粘贴的顺序。 + // 屏蔽操作等长替换,因此两边拿到的 index 可以直接比较。 + const found: Array<{ + index: number; + repositoryFullName: string; + originalValue: string; + status: ImportedRepositoryCandidate['status']; + matchedBy: ImportCandidateMatchedBy; + reason?: ImportFailureReason; + }> = []; + + for (const match of text.matchAll(GITHUB_URL_PATTERN)) { + const parsed = resolveRepositoryFromGitHubUrl(match[1]); + const resolved = 'repositoryFullName' in parsed; + found.push({ + index: match.index ?? 0, + repositoryFullName: resolved ? parsed.repositoryFullName : '', + originalValue: match[0], + status: resolved ? 'pending' : 'invalid', + matchedBy: 'github-url', + reason: resolved ? undefined : parsed.reason, + }); + } + + // URL 已经被消费过,屏蔽掉它们的路径段再找裸写法,避免 `releases/tag` 之类被误当成 slug。 + for (const match of maskGitHubUrls(text).matchAll(BARE_SLUG_PATTERN)) { + const [, owner, repo] = match; + if (!isPlausibleBareSlug(owner, repo)) continue; + const fullName = normalizeRepositoryFullName(owner, repo); + if (!fullName) continue; + found.push({ + index: match.index ?? 0, + repositoryFullName: fullName, + originalValue: match[0], + status: 'pending', + matchedBy: 'bare-slug', + }); + } + + found.sort((left, right) => left.index - right.index); + + for (const entry of found) { + push(entry.repositoryFullName, entry.originalValue, entry.status, { + reason: entry.reason, + matchedBy: entry.matchedBy, + confidence: entry.matchedBy === 'github-url' ? 'high' : 'low', + }); + } + } + + return { + candidates, + inputErrors, + stats: { + scanned: strings.length, + valid: candidates.filter((candidate) => candidate.status === 'pending').length, + duplicates: candidates.filter((candidate) => candidate.status === 'duplicate').length, + invalid: candidates.filter((candidate) => candidate.status === 'invalid').length, + }, + }; +} + +/** + * 便捷入口:从已同步的仓库推导本地小写 `owner/repo` 集合,供 `alreadyStarred` 标记复用。 + * 保持纯函数语义——提取阶段不读 store。预览阶段可再叠加 My Apps 关联等本地数据。 + */ +export function toLocalRepositoryNameSet( + repositories: readonly Pick<Repository, 'full_name'>[] | undefined, +): Set<string> { + return new Set((repositories ?? []).map((repository) => repository.full_name.toLowerCase())); +} diff --git a/src/utils/repositoryMerge.ts b/src/utils/repositoryMerge.ts index b94c14149..971357a7b 100644 --- a/src/utils/repositoryMerge.ts +++ b/src/utils/repositoryMerge.ts @@ -16,6 +16,15 @@ const LOCAL_REPOSITORY_FIELDS: Array<keyof Repository> = [ 'category_locked', 'last_edited', 'vector_indexed_at', + // GitHub 原生状态字段:后端不存储,但 Repository Health / 筛选依赖它们。 + // 必须同时出现在 CLIENT_ONLY_REPOSITORY_FIELDS(不参与后端同步指纹), + // 否则每次拉取都会把它们清空并触发一次多余的「已变化」判定。 + 'archived', + 'disabled', + 'fork', + 'is_template', + 'open_issues_count', + 'default_branch', ]; /** @@ -30,6 +39,13 @@ export const CLIENT_ONLY_REPOSITORY_FIELDS: ReadonlySet<keyof Repository> = new 'analysis_error', 'has_fetched_releases', 'last_release_fetch_time', + // 见 LOCAL_REPOSITORY_FIELDS 中的同名字段:成对出现,缺一不可。 + 'archived', + 'disabled', + 'fork', + 'is_template', + 'open_issues_count', + 'default_branch', ]); /** Drop client-only fields from a repo list, projecting the shape the backend diff --git a/versions/version-info.xml b/versions/version-info.xml index a3269a42b..5ebb56d12 100644 --- a/versions/version-info.xml +++ b/versions/version-info.xml @@ -632,4 +632,35 @@ </changelog> <downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/tag/v0.8.1</downloadUrl> </version> + <version> + <number>0.9.0</number> + <releaseDate>2026-09-19</releaseDate> + <changelog> + <item>feat: Added objective Repository Health facts (status, activity, release, community, maintenance and maturity) with grouped Activity/Maintenance/Community/Maturity display in the repository release sheet; Core reports conservative observations only and never an overall health score.</item> + <item>feat: Reused Repository Health facts across repository filters, sorting, MCP evidence, AI analysis prompts and the plugin snapshot, and captured GitHub's archived/disabled/fork/template/open-issues/default-branch fields locally.</item> + <item>feat: Added a trusted local plugin platform with a sandboxed plugin page host, capability router, isolated per-plugin storage, permission and lifecycle management, and a documented Plugin API v1.</item> + <item>feat: Added release processors, repository actions, exporters, host-mediated plugin AI and web search, and the Smart Release Recommendation example plugin, all behind explicit manifest permissions.</item> + </changelog> + <downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.9.0/github-stars-manager-0.9.0.dmg</downloadUrl> + </version> + <version> + <number>0.10.0</number> + <releaseDate>2026-09-19</releaseDate> + <changelog> + <item>feat: Added installable asset detection that identifies which release assets can be installed on the current device (Windows EXE/MSI/portable ZIP/7z, macOS DMG/PKG/ZIP/universal, Linux DEB/RPM/AppImage/tar.gz, Android APK) together with architecture (x64/arm64/x86/universal), package type, confidence and a human-readable reason.</item> + <item>feat: Excluded non-installable assets (source code, checksums, signatures, debug symbols, blockmaps, SBOM) and assets that declare conflicting platforms or architectures, and surfaced every candidate plus the exclusion reasons in the repository release sheet.</item> + <item>feat: Kept the user in control — nothing downloads or runs automatically, no asset is claimed to be safe, alternative candidates and the full asset list stay manually selectable, and the existing download path is reused unchanged.</item> + </changelog> + <downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.10.0/github-stars-manager-0.10.0.dmg</downloadUrl> + </version> + <version> + <number>0.11.0</number> + <releaseDate>2026-09-19</releaseDate> + <changelog> + <item>feat: Added batch repository intake extraction: pasted text, Markdown and JSON are normalized to owner/repo candidates, covering plain slugs, github.com URLs (release/issue/PR/tree/blob and other sub-paths) and .git/query/fragment variants.</item> + <item>feat: Treated GitHub site paths (orgs, topics, settings, sponsors, marketplace, apps) as explicit invalid entries with a reason instead of dropping them silently, and deduplicated candidates case-insensitively while keeping first-seen order, casing and the original fragment.</item> + <item>fix: Kept bare owner/repo detection conservative: code-like directory names, file extensions and common word pairs such as src/utils, docs/guide.md, and/or and TCP/IP are rejected, and accepted bare slugs are marked low confidence for user review.</item> + </changelog> + <downloadUrl>https://github.com/AmintaCCCP/GithubStarsManager/releases/download/v0.11.0/github-stars-manager-0.11.0.dmg</downloadUrl> + </version> </versions>