Skip to content
dsh.fish
Bundle

dsh-tool-mining

Repository mining for DeepSeek Harness: contributor spread, median pull-request merge time with a weekly trend, bus factor with single-author files, and issue backlog age. Uses the GitHub REST API.

Source
yuehancn
License
MIT
Updated
Updated 8 days ago

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.

---

## 一行安装

```bash
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 页面上显示的不是同一个数。

---

## 配置

```yaml
# ~/.dsh/profiles/<profile>/cordis.patch.yml
- id: tool-mining
  config:
    token: ghp_xxxxxxxxxxxxxxxxxxxx
    maxPages: 5
    perPage: 100
```

| 字段 | 默认 | 说明 |
|---|---|---|
| `token` | `""` | GitHub PAT。**强烈建议配**:匿名一小时 60 次,多页分析几秒就耗尽。公开仓库**不配也能用** |
| `apiBase` | `https://api.github.com` | API 基地址,可指向企业版 |
| `maxPages` | `5` | 每份列表最多翻几页 |
| `perPage` | `100` | 每页多少条 |
| `timeoutMs` | `600000` | 单次调用预算(10 分钟) |
| `status`/`contributors`/`velocity`/`busfactor`/`issues` | `true` | 按需关掉某个工具 |

---

## 安全说明

- **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`,
而插件读的是另一个对象。

---

## 跑测试

```bash
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.mjs` | 108 | `Config` 默认值与覆盖、注册开关、schema 归一化、`isBot`(8 种写法)、中位数(含「离群值几乎不动它」)、百分位插值、均值(含「离群值把它拖到 202」)、`daysBetween`、**bus factor**(7 种分布,含「正好 50% 不够」)、周分桶(含周日锚点)、**错误映射**(401 / 404 / 500 / 配额耗尽 / 网络 / 非 JSON / 无 fetch)、**分页**(多页、触顶截断、非列表响应) |
| `test-integration.mjs` | 99 | 五个工具各自对着脚本化 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.mjs` | 46 | **一条完整链路**:status → contributors → velocity → busfactor → issues,全对着**同一个自洽的仓库故事**。断言「一个账号占 70%」这个结论**是从原始计数推出来的**,而不是被印出来的;并做**两次读同一份 fixture 结果逐字节一致**的确定性检查 |

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

---

## 许可

MIT

Install

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

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source