gitlink-cli/skills/gitlink-stale/references/gitlink-stale-scan.md

347 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 Issue100 条)
┌─────────────────────────────────┐
│ 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 提示词。