forked from Gitlink/gitlink-cli
11 KiB
11 KiB
gitlink-stale — 扫描算法详解
本文档面向 AI Agent 开发者 和 想理解扫描细节的工程师。 普通使用者只需阅读 SKILL.md 即可。
1. 输入数据
1.1 Issue 字段(来自 issue +list --state open --format 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 数组(评论历史):
{
"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)
{
"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 标准计算
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 阈值决策
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):
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 分页拉取
# 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:
{
"$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):
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 中的 AI Agent 提示词。