forked from Gitlink/gitlink-cli
347 lines
11 KiB
Markdown
347 lines
11 KiB
Markdown
# gitlink-stale — 扫描算法详解
|
||
|
||
> 本文档面向 **AI Agent 开发者** 和 **想理解扫描细节的工程师**。
|
||
> 普通使用者只需阅读 [SKILL.md](../SKILL.md) 即可。
|
||
|
||
## 1. 输入数据
|
||
|
||
### 1.1 Issue 字段(来自 `issue +list --state open --format json`)
|
||
|
||
```json
|
||
{
|
||
"number": 142, // project_issues_index,网页 URL 中的序号
|
||
"subject": "登录页面点击登录无反应",
|
||
"description": "线上环境用户反馈...",
|
||
"status_id": 1, // 1=open
|
||
"tracker_id": 1,
|
||
"priority_id": 2, // 2=normal
|
||
"issue_tags": [], // 已有标签
|
||
"assigned_to_id": null,
|
||
"author": {"login": "user01"},
|
||
"updated_at": "2026-04-15T10:30:00Z", // 关键:最后活动时间
|
||
"created_at": "2026-02-10T08:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 1.2 Issue 详情字段(来自 `issue +view --number N --format json`)
|
||
|
||
详情接口会额外返回 `journals` 数组(评论历史):
|
||
|
||
```json
|
||
{
|
||
"number": 142,
|
||
"...": "...同上",
|
||
"journals": [
|
||
{
|
||
"id": 1234,
|
||
"notes": "我先确认一下复现步骤",
|
||
"created_at": "2026-04-15T10:30:00Z",
|
||
"user": {"login": "dev-li"}
|
||
},
|
||
{
|
||
"id": 1235,
|
||
"notes": "已复现,正在排查",
|
||
"created_at": "2026-04-22T14:20:00Z",
|
||
"user": {"login": "dev-li"}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 1.3 PR 字段(来自 `pr +list --state open --format json`)
|
||
|
||
```json
|
||
{
|
||
"pull_request_number": 8, // 网页 URL 中的序号(注意:不是 id)
|
||
"id": 9012, // 内部数据库 id
|
||
"title": "feat: 新增搜索功能",
|
||
"state": "open",
|
||
"pull_request_status": 0, // 0=open, 1=merged, 2=closed(关键过滤字段)
|
||
"updated_at": "2026-04-15T10:30:00Z",
|
||
"created_at": "2026-02-10T08:00:00Z",
|
||
"user": {"login": "contributor-a"}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **PR state 过滤的已知行为**:`pr +list --state open` 的 `--state` 参数仅影响统计计数,返回列表可能包含所有状态。**必须**在客户端按 `pull_request_status == 0` 二次过滤。
|
||
|
||
---
|
||
|
||
## 2. 时间计算算法
|
||
|
||
### 2.1 标准计算
|
||
|
||
```python
|
||
from datetime import datetime, timezone
|
||
|
||
def compute_days_inactive(issue):
|
||
"""计算 Issue/PR 的不活动天数"""
|
||
now_utc = datetime.now(timezone.utc)
|
||
|
||
# 优先使用 updated_at
|
||
if issue.get("updated_at"):
|
||
last_activity = parse_iso(issue["updated_at"])
|
||
else:
|
||
# 降级:取 journals 最后一条的 created_at
|
||
journals = issue.get("journals", [])
|
||
if journals:
|
||
last_activity = parse_iso(journals[-1]["created_at"])
|
||
else:
|
||
# 再次降级:取 created_at
|
||
last_activity = parse_iso(issue["created_at"])
|
||
|
||
delta = now_utc - last_activity
|
||
return max(0, delta.days)
|
||
```
|
||
|
||
### 2.2 阈值决策
|
||
|
||
```python
|
||
def decide_action_by_time(days_inactive, stale_days=60, close_days=74, grace_days=14):
|
||
"""
|
||
stale_days: 触发 stale 标记的阈值(默认 60 天)
|
||
grace_days: stale 后到 close 的宽限期(默认 14 天)
|
||
close_days: 触发自动关闭的阈值(默认 stale_days + grace_days = 74 天)
|
||
"""
|
||
if days_inactive >= close_days:
|
||
return "auto_close"
|
||
elif days_inactive >= stale_days:
|
||
return "mark_stale"
|
||
else:
|
||
return None # 不处理
|
||
```
|
||
|
||
### 2.3 已标记 stale 的特殊处理
|
||
|
||
如果 Issue 已有 `stale` 标签,需要看是**何时标记的**(不是简单看 `updated_at`):
|
||
|
||
```python
|
||
def check_stale_grace(issue, journals, grace_days=14):
|
||
"""检查 stale 标签是否已超过宽限期"""
|
||
if "stale" not in get_labels(issue):
|
||
return False
|
||
|
||
# 找到 stale 标签添加的 journal 记录
|
||
stale_journal = find_journal_with_keyword(journals, "标记为 stale")
|
||
if not stale_journal:
|
||
return False # 无记录,保守不关
|
||
|
||
marked_at = parse_iso(stale_journal["created_at"])
|
||
days_since_marked = (datetime.now(timezone.utc) - marked_at).days
|
||
|
||
return days_since_marked >= grace_days
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 批量扫描策略
|
||
|
||
### 3.1 分页拉取
|
||
|
||
```bash
|
||
# GitLink API 默认每页 15 条,可指定 limit 上限 100
|
||
gitlink-cli issue +list \
|
||
--owner <owner> --repo <repo> \
|
||
--state open \
|
||
--limit 100 \
|
||
--format json
|
||
```
|
||
|
||
### 3.2 客户端过滤流程
|
||
|
||
```
|
||
全量 open Issue(100 条)
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ Filter 1: 时间过滤 │
|
||
│ - days_inactive >= stale_days │
|
||
└────────────┬────────────────────┘
|
||
▼
|
||
~30 条候选(30%)
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ Filter 2: 白名单豁免 │
|
||
│ - 排除 pinned/security/roadmap │
|
||
└────────────┬────────────────────┘
|
||
▼
|
||
~20 条候选
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ Filter 3: 详情拉取 │
|
||
│ - issue +view --number N │
|
||
│ - 含 journals │
|
||
└────────────┬────────────────────┘
|
||
▼
|
||
~20 条详情
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ Filter 4: AI 真假僵尸判断 │
|
||
│ - 见 gitlink-stale-judge.md │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
### 3.3 API 调用次数估算
|
||
|
||
| 阶段 | 调用次数 | 备注 |
|
||
|------|---------|------|
|
||
| 列表拉取 | 1-2 | 一次 100 条 |
|
||
| 仓库标签 | 1 | 缓存复用 |
|
||
| 详情拉取 | N | N = 候选数 |
|
||
| AI 分析 | 0 | 本地推理 |
|
||
| **总计** | `N + 3` | N 通常 ≤ 30 |
|
||
|
||
---
|
||
|
||
## 4. 输出 Schema
|
||
|
||
完整扫描报告遵循以下 JSON Schema:
|
||
|
||
```json
|
||
{
|
||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||
"type": "object",
|
||
"required": ["repository", "scanned_at", "thresholds", "summary", "items"],
|
||
"properties": {
|
||
"repository": {"type": "string", "pattern": "^[^/]+/[^/]+$"},
|
||
"scanned_at": {"type": "string", "format": "date-time"},
|
||
"thresholds": {
|
||
"type": "object",
|
||
"required": ["stale_days", "close_days"],
|
||
"properties": {
|
||
"stale_days": {"type": "integer"},
|
||
"close_days": {"type": "integer"}
|
||
}
|
||
},
|
||
"summary": {
|
||
"type": "object",
|
||
"required": ["total_open_issues", "total_open_prs", "stale_candidates", "close_candidates", "exempt", "needs_review"],
|
||
"properties": {
|
||
"total_open_issues": {"type": "integer"},
|
||
"total_open_prs": {"type": "integer"},
|
||
"stale_candidates": {"type": "integer"},
|
||
"close_candidates": {"type": "integer"},
|
||
"exempt": {"type": "integer"},
|
||
"needs_review": {"type": "integer"}
|
||
}
|
||
},
|
||
"items": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["type", "number", "title", "days_inactive", "ai_analysis", "recommended_action"],
|
||
"properties": {
|
||
"type": {"type": "string", "enum": ["issue", "pr"]},
|
||
"number": {"type": "integer"},
|
||
"title": {"type": "string"},
|
||
"last_activity": {"type": "string", "format": "date-time"},
|
||
"days_inactive": {"type": "integer"},
|
||
"current_labels": {"type": "array", "items": {"type": "string"}},
|
||
"ai_analysis": {
|
||
"type": "object",
|
||
"required": ["truly_stale", "confidence", "reason"],
|
||
"properties": {
|
||
"truly_stale": {"type": "boolean"},
|
||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||
"reason": {"type": "string"},
|
||
"exempt": {"type": "boolean"},
|
||
"exempt_reason": {"type": ["string", "null"]}
|
||
}
|
||
},
|
||
"recommended_action": {"type": "string", "enum": ["mark_stale", "auto_close", "skip", "needs_review"]},
|
||
"next_review_date": {"type": ["string", "null"]}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 边界情况
|
||
|
||
| 情况 | 处理 |
|
||
|------|------|
|
||
| `updated_at` 缺失或为空 | 降级到 `journals` 最后一条的 `created_at`;再次降级到 `created_at` |
|
||
| 时区异常(如未来时间) | 视为 0 天不活动,跳过 |
|
||
| `journals` 数组很大(> 100 条) | 仅取最后 5 条用于 AI 判断 |
|
||
| Issue 没有 `number` 字段 | 跳过,记录到 errors |
|
||
| API 限流(HTTP 429) | 退避后重试,最多 3 次 |
|
||
| 网络错误 | 跳过当前 Issue,继续下一个 |
|
||
| 仓库 archived 或 read-only | 跳过整个仓库,提示用户 |
|
||
|
||
---
|
||
|
||
## 6. 性能建议
|
||
|
||
| 规模 | 建议 |
|
||
|------|------|
|
||
| ≤ 50 个 open Issue | 单次扫描,内存缓存元数据 |
|
||
| 50-200 个 | 分页拉取,每页 100 条 |
|
||
| 200-500 个 | 强制分批处理,每批 20 个 |
|
||
| > 500 个 | 建议夜间运行 + 限定时间范围(如只扫最近 1 年的) |
|
||
|
||
API 调用次数:`N_list_pages * 1 + N_candidates * 1 (view) + 1 (tags) ≈ N_candidates + 5`。
|
||
|
||
---
|
||
|
||
## 7. 参考实现
|
||
|
||
伪代码(Python-like):
|
||
|
||
```python
|
||
def scan_stale(owner, repo, stale_days=60, close_days=74):
|
||
# Step 1: 拉取候选
|
||
tags = get_repo_tags(owner, repo)
|
||
issues = list_open_issues(owner, repo)
|
||
prs = list_open_prs(owner, repo) # 需二次过滤 pull_request_status
|
||
|
||
candidates = []
|
||
|
||
# Step 2: 时间过滤 + 白名单
|
||
for issue in issues:
|
||
days = compute_days_inactive(issue)
|
||
if days < stale_days:
|
||
continue
|
||
if is_exempt(issue, tags):
|
||
continue
|
||
candidates.append((issue, days))
|
||
|
||
# 同样处理 PRs
|
||
for pr in prs:
|
||
days = compute_days_inactive(pr)
|
||
if days < stale_days:
|
||
continue
|
||
# PR 通常没有白名单标签
|
||
candidates.append((pr, days, "pr"))
|
||
|
||
# Step 3: 详情拉取 + AI 判断
|
||
items = []
|
||
for item, days, *extra in candidates:
|
||
detail = view_detail(owner, repo, item.number)
|
||
analysis = ai_judge_stale(detail)
|
||
|
||
items.append({
|
||
"type": extra[0] if extra else "issue",
|
||
"number": item.number,
|
||
"title": item.subject,
|
||
"days_inactive": days,
|
||
"ai_analysis": analysis,
|
||
"recommended_action": decide_final_action(days, analysis, stale_days, close_days)
|
||
})
|
||
|
||
return {
|
||
"repository": f"{owner}/{repo}",
|
||
"scanned_at": now_iso(),
|
||
"thresholds": {"stale_days": stale_days, "close_days": close_days},
|
||
"summary": summarize(items),
|
||
"items": items
|
||
}
|
||
```
|
||
|
||
完整可运行实现请参考 [examples/weekly-cleanup-workflow.md](../examples/weekly-cleanup-workflow.md) 中的 AI Agent 提示词。
|