Vai al contenuto principale
Y

dsh-tool-mining

yuehancn/dsh-tool-mining

Turn a GitHub repository's raw counts into a defensible judgement — contributor concentration and bus factor with single-author files, merge time reported as a median beside the mean so an outlier is visible rather than hidden, and an issue backlog with pull requests and drafts filtered out. Reads the public REST API with no SDK, and never claims a truncated page is the whole answer.

Installazione

dsh plugin --profile web add github:yuehancn/dsh-tool-mining

README

dsh-tool-mining

把一堆仓库数字变成一句能站住的判断 —— 先看谁在写代码、多久合一次, 再看这份工作有多依赖一个人,最后看 backlog 是不是在烂。

给 DeepSeek Harness 用的自建工具插件:读公开的 GitHub REST API, 不装任何 SDK,只用 fetch。

Compatibility: built and tested against dsh 0.2.0-rc.2 (preview). The apply(ctx) plugin spec is stable; verify against your own dsh version if newer.


一行安装

dsh plugin --profile desktop add github:yuehancn/dsh-tool-mining

支持的 profile:desktop(桌面版)/ web(Web 版)。装完重启 dsh 即可用。


为什么需要它

GitHub 自己就把这些数字摆在那儿了 —— 贡献者页有排名,PR 列表有日期。 问题不是拿不到数字,而是这些数字很容易被读错:

  • 均值是有毒的。 一个 40 天没人理的 PR,能把一批 1~3 天合并的 PR 的均值 从 3 天拖到 10.8 天。而「这个仓库合 PR 很快」这句话,恰恰是由均值说出来的。 中位数顶着这一个离群值,说 3 天 —— 这才是真相。
  • 「贡献者 300 人」可能是废话。 里面可能有一半是 [bot],剩下的人里 一个人写了 60%。300 这个数看着很热闹,但对「这人要是休假了怎么办」这个问题, 答案是 1。
  • Issues API 会把 PR 混进来。 你用 /issues 数 backlog,数到的其实是 「issue + PR」。而 draft PR 只存在于 /pulls 端点 —— 想排掉它,得再问一次。 少问这一次,backlog 就会虚高。
  • 分页会悄悄截断。 不跟 Link: rel="next",你拿到的是第一页, 但你以为拿到了全部。这是最坏的一种错:答案看起来很正常,就是只有一半。

这个插件把「看清楚」和「下判断」分开:

mining_status(owner, repo)        →  API 通不通 / 还剩多少配额
mining_contributors(owner, repo)  →  70% 集中在一人 / bus factor 1
mining_velocity(owner, repo)      →  中位 3 天,均值被拖到 10.8 天(两个都报)
mining_busfactor(owner, repo)     →  4 个文件只有一个人碰过
mining_issues(owner, repo)        →  一半 issue 没人回,中位年龄 10 天

关键设计:mining_velocity 同时报中位数和均值。只报一个数就是替调用方 做了判断,而这两个数的分歧本身就是最有用的信息。


五个工具

mining_status

报 API 能不能通、token 配没配、这一天还剩多少请求配额、重置时间, 以及可用操作和分页上限。多页分析前先问一句 —— 匿名会话一小时只有 60 次, 读两次大仓库就见底了。

mining_contributors(owner, repo, limit?)

按提交数排名,并说明工作集中到什么程度:第一名占多少、前五名占多少、 需要几个人才能覆盖一半提交(halfOfWorkNeeds)。

参数说明
limit单独列出多少人,默认全列

机器人和人是分开的。 份额算在人类提交里,不算 bot 的提交 —— 否则一个每天提 5 次的 dependabot 会把一个人的真实占比冲淡。 所以你会看到 topContributorShare 比「70/100」略高(是 70/98): 这是故意的,bot 不该稀释维护信号。

mining_velocity(owner, repo, weeks?, includeBots?)

从 PR 被开到被合,量出中位数和 p90,再加每周合并数的时间线 —— 让「变慢了」表现为趋势,而不是一个孤零零的数字。

返回值说明
medianMergeDays主指标,扛得住离群值
meanMergeDays一起报,专门为了让两者的差距可见
p90MergeDays线性插值,不是取最近的样本
trend按周分桶,新的一周在最后

p90 用插值算:5 个样本 [1,2,3,8,40] 的 p90 位置是 0.9 × 4 = 3.6, 落在第 4 个和第 5 个之间 → 8 + (40−8) × 0.6 = 27.2。 如果按「取最近的样本」实现,这里会报 40 —— 那样 p90 就退化成最大值了。

mining_busfactor(owner, repo, commits?, sampleFiles?)

用一份 commit 列表同时回答两个问题:每个作者的提交数, 以及哪些文件只被一个人碰过。

比逐个文件问「谁改的」便宜得多 —— 后者是一个文件一次请求。 commit 列表本身带作者和文件列表,一遍扫完。

参数说明
commits分析多少个近期提交,默认 300,上限 maxPages × perPage
sampleFiles最多列几个单作者文件,默认 10

判据是严格大于一半:一个人占正好 50% 不算够 —— 需要第二个作者。 risk 按 bus factor 给出 high / moderate / low,话术里写明判据 (「一个账号握着超过一半的近期工作」),而不是只给一个词。

文件列表可能缺席。 commit 列表端点经常不回 files 数组。 这时候不是「没有单作者文件」,而是「这次查不出来」—— 两种结论天差地别, 所以 notes 里会明说,而不是让空数组自己暗示。

mining_issues(owner, repo, weeks?)

数 backlog:开了多少、中间那个等了多久、多少条一个回复都没有、 标签分布、以及每周新增的时间线。

做了三件过滤,每一件都写进 notes:

  1. Issues API 会把 PR 混进来 → 按有没有 pull_request 字段排掉。
  2. draft PR 只存在于 /pulls → 额外查一次开的 PR,把 draft 号收集起来排掉。
  3. 仓库的 open_issues_count 是 issue + PR 合在一起的 → 明说这个数是 过滤后的 issue 数,和 GitHub 页面上显示的不是同一个数。

配置

# ~/.dsh/profiles/<profile>/cordis.patch.yml
- id: tool-mining
  config:
    token: ghp_xxxxxxxxxxxxxxxxxxxx
    maxPages: 5
    perPage: 100
字段默认说明
token""GitHub PAT。强烈建议配:匿名一小时 60 次,多页分析几秒就耗尽。公开仓库不配也能用
apiBasehttps://api.github.comAPI 基地址,可指向企业版
maxPages5每份列表最多翻几页
perPage100每页多少条
timeoutMs600000单次调用预算(10 分钟)
status/contributors/velocity/busfactor/issuestrue按需关掉某个工具

安全说明

  • token 只进请求头,不进 URL、不进日志、不进错误信息。 401 的报错 只说「GitHub 拒绝了 token」,不会把 token 回显出来。
  • 只用 fetch,不落任何文件,不写磁盘。
  • owner / repo 一律 encodeURIComponent —— a/b c 会变成 a%2Fb%20c, 不会把路径拼歪(有专门的断言盯这一点)。
  • 错误按类型分辨,不糊成一句「请求失败」:401(token 无效)、 403 且配额为 0(配额耗尽,附重置时间)、404(仓库不存在, 或 token 看不见私有仓库)、非 JSON 响应(比如被网关挡了)。
  • 分页有上限,截断了就明说(truncated: true + 一句 note), 不给一个「看起来完整」的半份答案。

实现说明:四个值得说的地方

1. 分页的总数要读「广告里的」per_page,不是「我要的」。 Link 头的 rel="last" 只给页号,总数得自己乘。最初写的是 最后一页 × 我请求的 perPage —— 错。GitHub 会在头里把它实际用的 per_page 写出来,两个数不一致时(比如我请求 100、它给 2), 总数就会算成 300 而真相是 6。正确做法是从 Link 头里把 per_page 抠出来 再乘。这个 bug 是逻辑测试里断言「多页取满」时抓出来的。

2. 中位数和均值要一起报,因为分歧本身是信息。 测试数据 [1,2,3,8,40]:中位 3 天,均值 10.8 天。断言里专门写了一条 「一个巨大的离群值几乎不动中位数」,和一条「一个离群值把均值拖到 202」 (另一组数据),把这两种行为钉在测试里。哪天有人把中位数换成均值, 测试会红。

3. 严格大于一半。 estimateBusFactor 的循环条件是 covered / total > 0.5。 正好 50% 不算够 —— 一个人占一半,意味着另一半在别人手里, 这个人休假仓库不会停。测试里有一条断言就叫 「exactly 50% is not enough」,专门防这个边界被改宽。

4. 份额的分母排除 bot。 mining_contributors 的 topContributorShare 分母是人类提交总数, 不是全部提交。所以同一个 70/100 的仓库,配 2 次 bot 提交后报的是 0.7143 而不是 0.70。这是刻意选择:bot 提交量不反映「知识集中度」, 把它算进分母等于让机器人稀释风险信号。

测试方式:HTTP 全部走一个 githubRequest 助手,fetchImpl 可注入。 集成与 e2e 套件装上脚本化的假 fetch,请求 URL 全部被记录、被断言 —— 整个测试套件一次网络都不碰。而且每个 fixture 装完假 fetch 会立刻调 assertNoNetwork:如果请求没打到假实现(说明打到了真网络),直接抛错。 这条守卫不是装饰 —— 它抓到了一个真实的 bug:测试往一个对象上写 fetchImpl, 而插件读的是另一个对象。


跑测试

mkdir -p node_modules/@deepseek-ai
cp -r "$HOME/.dsh/profiles/desktop/node_modules/@deepseek-ai/." node_modules/@deepseek-ai/

node _test/run-all.mjs

三个套件,346 条断言全绿(对着真实 @deepseek-ai/dsh-tools 跑,不 mock):

套件断言内容
test-logic.mjs108Config 默认值与覆盖、注册开关、schema 归一化、isBot(8 种写法)、中位数(含「离群值几乎不动它」)、百分位插值、均值(含「离群值把它拖到 202」)、daysBetween、bus factor(7 种分布,含「正好 50% 不够」)、周分桶(含周日锚点)、错误映射(401 / 404 / 500 / 配额耗尽 / 网络 / 非 JSON / 无 fetch)、分页(多页、触顶截断、非列表响应)
test-integration.mjs99五个工具各自对着脚本化 API 跑通:4 人 60/25/10/5 → bus factor 1;5 个 PR 在 1/2/3/8/40 天合并 → 中位 3、均值 10.8、p90 插值 27.2;单作者文件识别与共享文件排除;空 backlog 不给 0 而是 undefined;owner/repo 真的进了 URL 且被编码(a%2Fb%20c)
test-e2e.mjs46一条完整链路:status → contributors → velocity → busfactor → issues,全对着同一个自洽的仓库故事。断言「一个账号占 70%」这个结论是从原始计数推出来的,而不是被印出来的;并做两次读同一份 fixture 结果逐字节一致的确定性检查

三个套件都是独立进程(一个崩了不会盖住其他两个的结果),退出码汇总。


许可

MIT

Plugin correlati