# 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 提示词。
