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

11 KiB
Raw Blame History

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 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

{
  "$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 提示词。