ci-workflows/scripts/stale-cleanup.py
feibisi 9d227a5db0
All checks were successful
Go 项目 CI / ci (push) Has been skipped
gitleaks 密钥泄露扫描 / gitleaks (push) Successful in -8h1m15s
TypeScript/JS 项目 CI / ci (push) Has been skipped
WordPress 插件 CI / ci (push) Has been skipped
refactor: stale-cleanup 改为独立 Python 脚本
- 避免 shell+Python 混合的转义问题
- 纯 Python 实现,urllib 无额外依赖
- 支持 --dry-run 模式
- 自动创建 stale 标签
2026-02-19 01:09:48 +08:00

121 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Stale Issue/PR 清理 Bot - 遍历所有原创仓库,标记/关闭过期 Issue 和 PR"""
import json
import os
import sys
import urllib.request
from datetime import datetime, timezone
API_BASE = "https://feicode.com/api/v1"
TOKEN = os.environ.get("FORGEJO_TOKEN", "")
DRY_RUN = "--dry-run" in sys.argv
ISSUE_STALE_DAYS = 60
ISSUE_CLOSE_DAYS = 74
PR_STALE_DAYS = 30
PR_CLOSE_DAYS = 44
PROTECTED_LABELS = {"pinned", "help-wanted", "bug", "security", "wontfix"}
def api(method, path, data=None):
url = f"{API_BASE}/{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {TOKEN}")
if body:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except Exception as e:
print(f" API error: {method} {path}: {e}", file=sys.stderr)
return None
def get_original_repos():
data = api("GET", "repos/search?limit=50&sort=updated&order=desc")
if not data:
return []
return [r["full_name"] for r in data.get("data", [])
if not r.get("mirror") and not r.get("fork") and not r.get("archived")]
def ensure_stale_label(repo):
labels = api("GET", f"repos/{repo}/labels?limit=50")
if labels:
for l in labels:
if l["name"] == "stale":
return l["id"]
result = api("POST", f"repos/{repo}/labels",
{"name": "stale", "color": "#ededed", "description": "长期无活动"})
if result and "id" in result:
print(f" 创建 stale 标签: ID={result['id']}")
return result["id"]
return None
def process_repo(repo):
label_id = ensure_stale_label(repo)
now = datetime.now(timezone.utc)
for item_type, stale_days, close_days in [
("issues", ISSUE_STALE_DAYS, ISSUE_CLOSE_DAYS),
("pulls", PR_STALE_DAYS, PR_CLOSE_DAYS),
]:
page = 1
while True:
items = api("GET", f"repos/{repo}/issues?state=open&type={item_type}&page={page}&limit=50")
if not items:
break
for item in items:
labels = {l["name"] for l in item.get("labels", [])}
if PROTECTED_LABELS & labels:
continue
updated = datetime.fromisoformat(item["updated_at"].replace("Z", "+00:00"))
age_days = (now - updated).days
number = item["number"]
title = item["title"][:60]
kind = "issue" if item_type == "issues" else "PR"
if age_days > close_days:
print(f" 关闭 {repo}#{number} ({age_days}天): {title}")
if not DRY_RUN:
api("PATCH", f"repos/{repo}/issues/{number}", {"state": "closed"})
api("POST", f"repos/{repo}/issues/{number}/comments",
{"body": f"{kind} 因长期无活动已自动关闭。如需继续讨论请重新打开。"})
elif age_days > stale_days and "stale" not in labels and label_id:
print(f" 标记 {repo}#{number} 为 stale ({age_days}天): {title}")
if not DRY_RUN:
api("POST", f"repos/{repo}/issues/{number}/labels", {"labels": [label_id]})
api("POST", f"repos/{repo}/issues/{number}/comments",
{"body": f"{kind} 已超过 {stale_days} 天无活动,已标记为 stale。如果 14 天内无新活动将自动关闭。"})
if len(items) < 50:
break
page += 1
def main():
if not TOKEN:
print("ERROR: FORGEJO_TOKEN not set", file=sys.stderr)
sys.exit(1)
print("=== Stale 清理开始 ===")
if DRY_RUN:
print("(dry-run 模式,不会实际修改)")
repos = get_original_repos()
print(f"发现 {len(repos)} 个原创仓库")
for repo in repos:
print(f"处理 {repo} ...")
process_repo(repo)
print("=== 清理完成 ===")
if __name__ == "__main__":
main()