forked from Ikalus1988/MisakaNet
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathleaderboard_watch.py
More file actions
320 lines (275 loc) · 10.3 KB
/
Copy pathleaderboard_watch.py
File metadata and controls
320 lines (275 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#!/usr/bin/env python3
"""
Leaderboard Watcher — 贡献榜变化检测与通知
在每次 main 分支更新时运行,计算当前贡献排行榜,
与上次快照对比,如果榜首发生变化则创建通知 Issue。
用法:
python3 scripts/leaderboard_watch.py # 正常运行(环境变量 GH_TOKEN 需设置)
python3 scripts/leaderboard_watch.py --dry-run # 只打印不写入
环境变量:
GH_TOKEN: GitHub Personal Access Token
GH_REPO: repo 名(默认 Ikalus1988/MisakaNet)
"""
from __future__ import annotations
import json
import os
import sys
import time
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError
REPO = os.environ.get("GH_REPO", "Ikalus1988/MisakaNet")
TOKEN = os.environ.get("GH_TOKEN", "")
DRY_RUN = "--dry-run" in sys.argv
REPO_ROOT = Path(__file__).resolve().parent.parent
LESSONS_DIR = REPO_ROOT / "lessons"
LEADERBOARD_FILE = REPO_ROOT / "data" / "leaderboard.json"
GRAPHQL_QUERY = """
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
defaultBranchRef {
target {
... on Commit {
history(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
oid
committedDate
author { user { login } name }
}
}
}
}
}
}
}
"""
def gh_api(method="GET", path="", data=None, graphql=None):
"""调用 GitHub API"""
if graphql:
url = "https://api.github.com/graphql"
body = json.dumps({"query": graphql, "variables": {
"owner": REPO.split("/")[0],
"repo": REPO.split("/")[1],
}}).encode()
else:
url = f"https://api.github.com/{path}"
body = json.dumps(data).encode() if data else None
headers = {
"Authorization": f"token {TOKEN}",
"User-Agent": "misakanet-leaderboard-bot",
"Accept": "application/vnd.github.v3+json",
}
req = Request(url, data=body, method=method, headers=headers)
try:
with urlopen(req) as resp:
return json.loads(resp.read())
except HTTPError as e:
print(f" HTTP {e.code}: {e.read().decode()[:200]}")
return None
def compute_leaderboard():
"""从 GitHub API 获取贡献数据,计算排行榜"""
print("Fetching commit history via GraphQL...")
contrib = {}
cursor = None
page = 0
while page < 20: # 最多 20 页 = 2000 commits
page += 1
q = GRAPHQL_QUERY
if cursor:
q = q.replace('$cursor: String', '$cursor: String', 1)
q = q.replace('after: $cursor', f'after: "{cursor}"')
# 直接用 hardcoded query 避免占位符问题
query = """
query {
repository(owner: "%s", name: "%s") {
defaultBranchRef {
target {
... on Commit {
history(first: 100, %s) {
pageInfo { hasNextPage endCursor }
nodes {
oid
committedDate
author { user { login } name }
}
}
}
}
}
}
}
""" % (REPO.split("/")[0], REPO.split("/")[1],
f'after: "{cursor}"' if cursor else "")
body = json.dumps({"query": query}).encode()
url = "https://api.github.com/graphql"
req = Request(url, data=body, method="POST", headers={
"Authorization": f"token {TOKEN}",
"User-Agent": "misakanet-leaderboard-bot",
})
try:
with urlopen(req) as resp:
data = json.loads(resp.read())
except HTTPError as e:
print(f" GraphQL HTTP {e.code}: {e.read().decode()[:200]}")
break
if "errors" in data:
print(f" GraphQL error: {data['errors']}")
break
try:
history = data["data"]["repository"]["defaultBranchRef"]["target"]["history"]
except (TypeError, KeyError):
print(" Failed to parse GraphQL response")
break
for node in history["nodes"]:
if not node:
continue
author = node.get("author", {}) or {}
login = (author.get("user") or {}).get("login") or author.get("name") or "unknown"
login = login.lower()
ts = node.get("committedDate", "")
if not ts:
continue
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
days_ago = (datetime.now(timezone.utc) - dt).days
except (ValueError, AttributeError):
days_ago = 365
# 时间衰减权重:30天半衰期
weight = max(0.1, 0.5 ** (days_ago / 30))
contrib[login] = contrib.get(login, 0.0) + weight
if not history["pageInfo"]["hasNextPage"]:
break
cursor = history["pageInfo"]["endCursor"]
time.sleep(0.5) # 限速保护
# 排除自产自销账号
EXCLUDE_LOGINS = {"misakanet-bot", "ikalus1988", "sheldonisspark-lab", "claude",
"actions-user", "cloudflare-workers-and-pages[bot]",
"dependabot[bot]", "pre-commit-ci[bot]"}
contrib = {k: v for k, v in contrib.items() if k not in EXCLUDE_LOGINS}
# Feature: lessons_contributed bonus — read source field from lesson frontmatter
lessons_bonus = {}
for lesson_file in sorted(LESSONS_DIR.rglob("*.md")):
if lesson_file.name in ("index.md", "TEMPLATE.md", "README.md"):
continue
if "_archive" in lesson_file.parts:
continue
text = lesson_file.read_text(encoding="utf-8", errors="replace")
m = re.match(r'^---\s*\n(\{.*?\})\s*\n---', text, re.DOTALL)
if m:
try:
fm = json.loads(m.group(1))
source = (fm.get("source") or "").lower().strip()
if source and source not in ("unknown", "contribute-api", "bootstrap", ""):
lessons_bonus[source] = lessons_bonus.get(source, 0) + 1
except json.JSONDecodeError:
pass
# Apply lessons_contributed bonus: each contributed lesson adds 0.5 points
for login in contrib:
bonus = lessons_bonus.get(login, 0) * 0.5
if bonus > 0:
contrib[login] += bonus
print(f" 📚 {login}: +{bonus:.1f} from {lessons_bonus.get(login, 0)} lessons")
# 排序
sorted_contrib = sorted(contrib.items(), key=lambda x: -x[1])
return [{"login": login, "score": round(score, 2)} for login, score in sorted_contrib]
def load_previous_leaderboard():
"""读取上次的排行榜快照"""
if LEADERBOARD_FILE.exists():
try:
return json.load(LEADERBOARD_FILE.open())
except (json.JSONDecodeError, OSError):
pass
return None
def save_leaderboard(data):
"""保存排行榜快照"""
LEADERBOARD_FILE.parent.mkdir(parents=True, exist_ok=True)
json.dump(data, LEADERBOARD_FILE.open("w"), indent=2)
print(f" Leaderboard saved to {LEADERBOARD_FILE}")
def create_notification_issue(new_top, old_top, changed):
"""创建贡献榜变化的 Issue"""
if old_top:
title = f"🏆 Leaderboard Update: {new_top['login']} is now #1!"
body_parts = [
f"## Leaderboard Change Detected",
"",
f"| | Previous | Current |",
f"|---|---|---|",
f"| **#1** | {old_top['login']} ({old_top['score']}) | **{new_top['login']} ({new_top['score']})** |",
f"| **Changed** | {len(changed)} spots changed |",
"",
"### Full Leaderboard",
"",
]
else:
title = f"🏆 First Leaderboard: {new_top['login']} takes #1!"
body_parts = [
f"## First Leaderboard Snapshot",
"",
"### Full Leaderboard",
"",
]
# 截取 Top 20
body_parts.append("| Rank | Contributor | Score |")
body_parts.append("|------|------------|-------|")
for rank, entry in enumerate(changed[:20], 1):
body_parts.append(f"| {rank} | {entry['login']} | {entry['score']} |")
body_parts.extend([
"",
"_Auto-generated by leaderboard-watcher. Updated on every merge to main._",
f"_Timestamp: {datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')}_",
])
if DRY_RUN:
print(f"\n[Dry-Run] Would create issue: {title}")
return True
result = gh_api("POST", f"repos/{REPO}/issues", {
"title": title,
"body": "\n".join(body_parts),
"labels": ["leaderboard", "automated"],
})
if result and "number" in result:
print(f" Issue #{result['number']} created: {result['html_url']}")
return True
return False
def main():
if not TOKEN:
print("❌ GH_TOKEN not set")
sys.exit(1)
print("=" * 50)
print(f"Leaderboard Watch — {REPO}")
print(f"Timestamp: {datetime.utcnow().isoformat()}Z")
print(f"Dry-run: {DRY_RUN}")
print("=" * 50)
# 1. 计算当前排行榜
print("\n📊 Computing leaderboard...")
current = compute_leaderboard()
if not current:
print("❌ Failed to compute leaderboard")
sys.exit(1)
print(f" Total contributors: {len(current)}")
print(f" #1: {current[0]['login']} ({current[0]['score']})")
# 2. 读取上次快照
previous = load_previous_leaderboard()
if previous:
print(f" Previous #1: {previous[0]['login']} ({previous[0]['score']})")
else:
print(" No previous leaderboard found (first run)")
# 3. 对比
changed = previous is None or (
previous[0]["login"] != current[0]["login"] and
abs(previous[0]["score"] - current[0]["score"]) > 0.5
)
if changed:
print(f"\n🔔 Leaderboard changed! Creating notification...")
old_top = previous[0] if previous else None
new_top = current[0]
create_notification_issue(new_top, old_top, current)
else:
print(f"\n✅ Leaderboard unchanged. No notification needed.")
# 4. 保存新快照
save_leaderboard(current)
print("\nDone.")
if __name__ == "__main__":
main()