#!/usr/bin/env python3 """isep-nag — 把「沒有人會叫的事」叫出來(inkstone/ISEP#93) ━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 有些事情**沒有人會叫**:一張票掛著等 leo 三天了、一個 milestone 逾期了、 一根棒子交回來躺著沒人接。它們不會壞掉、不會噴錯,**只是靜靜地過期**。 leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生。** ━━ 🔴 刻意不做什麼(這不是簡化,是判斷)━━━━━━━━━━━━━━━━━━━━━━━━━━ **不做排程輪詢、不做 webhook fan-out、不掛 Gitea Actions。** · 頂層 `CLAUDE.md`「避免再被 GitHub flag 的硬規則」:禁一事件 fan-out 到多 repo · `issue-handle` skill:「**有事才讀**」,換成 Gitea 也不放寬 · ISEP v0.6.0 §8.2:第一版不依賴 runner——「壞掉的形式是『以為有人在跑』, 比『沒人跑』貴得多」 ⇒ 本支只在**兩個時機**跑:開 session 時順手一次、人要看的時候手動一次。 **已知限制,誠實寫在這裡**:它只在 CC 醒著的時候會叫。 「CC 整個沒在跑」的那一段補不到——那要 leo 決定要不要開 Gitea 的通知權限 (機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。 **但腳本版本身是完整可用的,不是半成品。** ━━ 撈三種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了 ② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天** ③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做 每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、 講「這對你意味什麼」而不是系統內部狀態。 🔴 **沒東西可報時會說「查過了,沒有」**,不會安靜結束—— 安靜跟壞掉長得一模一樣,而分不出來的東西沒有人敢信。 ━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ isep-nag 撈一次,白話印出來 isep-nag --json 同一份資料的機器格式 isep-nag --notify 撈完順便發 Telegram(走 isep-notify,發不出去有退路) isep-nag --short 只印 Telegram 版(很短,leo 手機上看的那則) isep-nag --both 長版+分隔線+短版,一次算完(開場的 hook 用這個) isep-nag --fixture <檔> 用假資料跑(離線測試用,完全不碰網路) isep-nag --now 把時鐘定住(測試用) 環境變數: ISEP_NAG_REPOS 只看這幾個 repo(空白分隔)。預設:整個 org 撈一次 ISEP_NAG_ORG 預設 inkstone ISEP_NAG_STALE_DAYS 「幾天沒動算掉棒」,預設 3 TICKET_HOST Gitea 位址,預設 https://git.uncle6.me """ import concurrent.futures as cf import json import os import re import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone # leo 看的是台北時間。容器多半跑在 UTC ⇒ 不指定就會印出一個他要自己換算的數字。 try: from zoneinfo import ZoneInfo TAIPEI = ZoneInfo("Asia/Taipei") except Exception: TAIPEI = timezone(timedelta(hours=8)) HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me" ORG = os.environ.get("ISEP_NAG_ORG") or "inkstone" STALE_DAYS = int(os.environ.get("ISEP_NAG_STALE_DAYS") or 3) HERE = os.path.dirname(os.path.abspath(__file__)) # 「等 leo」的兩個訊號:掛 Human 標籤,或指派給他本人。 HUMAN_LABEL = "Human" LEO_LOGINS = {"leo", "leo21c", "Leo"} # 「有人領了」的狀態標籤——掛著這個卻很久沒動,就是棒子掉在地上。 BATON_LABELS = {"s/doing", "s/review"} def token(): for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"): v = os.environ.get(env) if v: return v root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() host = HOST.split("//")[-1].rstrip("/") try: out = subprocess.run(["git", "-C", root, "remote", "-v"], capture_output=True, text=True, timeout=20).stdout except Exception: out = "" for line in out.splitlines(): if host in line: m = re.search(r"//[^:/]+:([^@]+)@", line) if m: return m.group(1) return None def api(path, tok): url = "%s/api/v1%s" % (HOST, path) req = urllib.request.Request(url, headers={"Authorization": "token %s" % tok}) try: return json.load(urllib.request.urlopen(req, timeout=25)) except Exception: return None def ts(s): """Gitea 的時間字串 → epoch。看不懂就回 None(不猜)。""" if not s: return None try: return int(datetime.fromisoformat(s.replace("Z", "+00:00")) .astimezone(timezone.utc).timestamp()) except Exception: return None def days(a, b): return max(0, int((a - b) // 86400)) # ══ 一、把資料撈回來(或從 fixture 讀)═══════════════════════════════════ def collect(fixture=None): """回 {"milestones": [...], "issues": [...]}。撈不到就回空的,不編。""" if fixture: with open(fixture) as f: return json.load(f) tok = token() if not tok: return {"milestones": [], "issues": [], "error": "拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"} repos = os.environ.get("ISEP_NAG_REPOS", "").split() if not repos: rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or [] repos = [r.get("full_name") for r in rows if r.get("full_name")] # 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。 # 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它, # 而被忍不了的東西下一步就是被關掉**。平行之後落在 3 秒上下。 milestones = [] with cf.ThreadPoolExecutor(max_workers=8) as pool: jobs = {pool.submit(api, "/repos/%s/milestones?state=open" % f, tok): f for f in repos} for job in cf.as_completed(jobs): full = jobs[job] try: rows = job.result() or [] except Exception: continue for m in rows: m = dict(m or {}) m["repo"] = full milestones.append(m) # 票用**一支跨 repo 的搜尋**撈,不是每個 repo 打一次: # 16 個 repo × 一次 = 16 通 API,那個形狀就開始像輪詢了。 # 而且只撈**這三種標籤**(Gitea 的 labels= 是 OR)——不是撈回全部再自己篩: # 全撈是 5 頁,篩過是 1–2 頁。 # ⚠️ 已知取捨:**沒掛 Human 標籤、只是被指派給 leo 的票,這條路撈不到。** # 真相源是標籤(`labels.yaml`),指派是輔助訊號 ⇒ 寧可漏掉那個邊角, # 也不要為它把開場多花 13 秒。要撈全部就設 ISEP_NAG_ALL_ISSUES=1。 want = ",".join(sorted({HUMAN_LABEL} | BATON_LABELS)) qs = "" if os.environ.get("ISEP_NAG_ALL_ISSUES") == "1" else \ "&labels=%s" % urllib.parse.quote(want) issues = [] for page in range(1, 6): rows = api("/repos/issues/search?state=open&type=issues&limit=50&page=%d%s" % (page, qs), tok) or [] if not rows: break issues.extend(rows) if len(rows) < 50: break return {"milestones": milestones, "issues": issues} # ══ 二、找出「該叫的事」═══════════════════════════════════════════════════ def findings(data, now): over, waiting, dropped = [], [], [] for m in data.get("milestones") or []: due = ts(m.get("due_on")) if not due or due >= now: continue # 沒設期限的不是節拍器;還沒到期的不叫 open_n = int(m.get("open_issues") or 0) if open_n == 0: continue # 票都關完了,只是沒人按關閉鈕——不吵 over.append({ "repo": m.get("repo") or "?", "title": m.get("title") or "(沒有名字)", "due": (m.get("due_on") or "")[:10], "late_days": days(now, due), "open": open_n, "closed": int(m.get("closed_issues") or 0), }) over.sort(key=lambda x: -x["late_days"]) for i in data.get("issues") or []: if i.get("pull_request"): continue # PR 有 pr-verdict-guard 在管,不重複點名 full = (i.get("repository") or {}).get("full_name") or "" # 🔴 只看設定的那個 org。跨 repo 搜尋端點會把**舊 org 的同名票**一起回來 # (實測:`Leo/Arcrun#85` 與 `inkstone/Arcrun#85` 是同一件事的兩份), # 照單全收會讓「26 張掉棒」裡有一半是重複——**灌水的清單等於沒有清單**。 if ORG and not full.startswith(ORG + "/"): continue labels = {(l or {}).get("name") for l in (i.get("labels") or [])} who = {(a or {}).get("login") for a in (i.get("assignees") or [])} upd = ts(i.get("updated_at")) or now row = { "ref": "%s#%s" % ((i.get("repository") or {}).get("full_name") or "?", i.get("number")), "title": (i.get("title") or "").strip(), "quiet_days": days(now, upd), "labels": sorted(labels), "url": i.get("html_url") or "", } if HUMAN_LABEL in labels or (who & LEO_LOGINS): waiting.append(row) elif (labels & BATON_LABELS) and row["quiet_days"] >= STALE_DAYS: dropped.append(row) waiting.sort(key=lambda x: -x["quiet_days"]) dropped.sort(key=lambda x: -x["quiet_days"]) return {"overdue_milestones": over, "waiting_on_leo": waiting, "dropped_batons": dropped} # ══ 三、講出來(白話)═════════════════════════════════════════════════════ MAX_ROWS = int(os.environ.get("ISEP_NAG_MAX_ROWS") or 8) BOTH_SEP = "␟--isep-nag-short--␟" # --both 的分隔線(不會出現在正文裡) def _tail(rows, shown): """超過封頂的部分收成一行。**不是藏起來,是講出來還有幾張。**""" rest = len(rows) - shown return [" · …另外還有 %d 張(`isep-nag --json` 看全部)" % rest] if rest > 0 else [] def _waited(d): """0 天講「今天剛掛上」——「等了 0 天」讀起來像壞掉的計數器。""" return "今天剛掛上" if d == 0 else "等了 %d 天" % d def _clip(s, n): s = (s or "").replace("\n", " ").strip() return s if len(s) <= n else s[:n - 1] + "…" def full_report(f, now, err=None): """給眼前看的長版。**沒東西也一定有話講。**""" when = datetime.fromtimestamp(now, timezone.utc).astimezone(TAIPEI).strftime("%Y-%m-%d %H:%M") out = ["🔔 催辦員查了一次(%s)——這些事沒有人會叫,所以它叫(inkstone/ISEP#93)" % when] if err: out += ["", "🔴 撈不到資料:%s" % err, " ⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。兩件事不要講成同一句。"] return "\n".join(out) n = sum(len(v) for v in f.values()) if n == 0: out += ["", "✅ **查過了,沒有。** 三類都乾淨:", " · 沒有逾期還開著票的 milestone", " · 沒有掛著等你的票", " · 沒有超過 %d 天沒動的 s/doing/s/review" % STALE_DAYS] return "\n".join(out) if f["overdue_milestones"]: out += ["", "⏰ **逾期的 milestone(%d 個)**——期限過了,票還開著," "代表那個版本交不出來了:" % len(f["overdue_milestones"])] for m in f["overdue_milestones"][:MAX_ROWS]: out.append(" · %s 「%s」——%s 到期,**逾期 %d 天**,還有 %d 張票沒關" % (m["repo"], m["title"], m["due"], m["late_days"], m["open"])) out += _tail(f["overdue_milestones"], MAX_ROWS) out.append(" ⇒ 對你意味著:不是改期限,就是把票搬走。**放著它只會繼續爛。**") if f["waiting_on_leo"]: out += ["", "🙋 **在等你的票(%d 張)**——只有你按得下去," "沒人替得了:" % len(f["waiting_on_leo"])] for i in f["waiting_on_leo"][:MAX_ROWS]: out.append(" · %s **%s**:%s" % (i["ref"], _waited(i["quiet_days"]), _clip(i["title"], 42))) out += _tail(f["waiting_on_leo"], MAX_ROWS) out.append(" ⇒ 對你意味著:回一個詞就能推動。不回,這幾件永遠不會發生。") if f["dropped_batons"]: out += ["", "🧤 **棒子掉在地上(%d 張)**——標著「有人在做」," "但超過 %d 天沒動:" % (len(f["dropped_batons"]), STALE_DAYS)] for i in f["dropped_batons"][:MAX_ROWS]: out.append(" · %s **靜了 %d 天**(%s):%s" % (i["ref"], i["quiet_days"], "/".join(i["labels"]), _clip(i["title"], 38))) out += _tail(f["dropped_batons"], MAX_ROWS) out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。") return "\n".join(out) def short_report(f, now, err=None): """Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**""" if err: return "[總管] 🔴 催辦員查不到資料(%s)。這不是「沒事」,是「沒查到」。" % _clip(err, 60) a, b, c = (len(f["overdue_milestones"]), len(f["waiting_on_leo"]), len(f["dropped_batons"])) if a + b + c == 0: return "[總管] ✅ 查過了,沒有:沒有逾期的 milestone、沒有在等你的票、沒有掉在地上的棒子。" lines = ["[總管] 🔔 有 %d 件沒人叫的事:" % (a + b + c)] if a: top = f["overdue_milestones"][0] lines.append("⏰ %d 個 milestone 逾期,最久的是「%s」(%s,逾期 %d 天,%d 張票沒關)" % (a, _clip(top["title"], 20), top["repo"].split("/")[-1], top["late_days"], top["open"])) if b: top = f["waiting_on_leo"][0] lines.append("🙋 %d 張票在等你,最久的 %s %s:%s" % (b, top["ref"], _waited(top["quiet_days"]), _clip(top["title"], 26))) if c: top = f["dropped_batons"][0] lines.append("🧤 %d 張標著「有人在做」卻沒人動,最久 %d 天:%s" % (c, top["quiet_days"], top["ref"])) lines.append("回「看」我就把完整清單貼出來。") return "\n".join(lines) # ══ 四、CLI ══════════════════════════════════════════════════════════════ def main(argv): fixture = now = None want_json = short = do_notify = both = False ticket = "inkstone/ISEP#93" i = 0 while i < len(argv): a = argv[i] if a == "--json": want_json = True elif a == "--short": short = True elif a == "--both": # 開場的 hook 用這個:長版+短版**一次算完**。 # 分兩次呼叫=打兩輪 Gitea=開場多等 5 秒,而那是 leo 每次開工的關鍵路徑。 both = True elif a == "--notify": do_notify = True elif a == "--fixture": i += 1; fixture = argv[i] elif a == "--now": i += 1; now = int(argv[i]) elif a == "--fallback-ticket": i += 1; ticket = argv[i] elif a in ("-h", "--help"): print(__doc__); return 0 else: print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr) return 2 i += 1 now = now if now is not None else int(time.time()) try: data = collect(fixture) except Exception as e: data = {"milestones": [], "issues": [], "error": "撈的時候炸了:%s" % e} err = data.get("error") f = findings(data, now) if both: print(full_report(f, now, err)) print(BOTH_SEP) print(short_report(f, now, err)) elif want_json: print(json.dumps({"now": now, "error": err, "findings": f}, ensure_ascii=False, indent=2)) elif short: print(short_report(f, now, err)) else: print(full_report(f, now, err)) if do_notify: # 🔴 走 isep-notify,不自己打 HTTP:那支才會先問閘、發不出去才有退路。 # 在這裡自己 urlopen 一次,就等於多開一條沒有退路的路。 p = subprocess.run( [sys.executable, os.path.join(HERE, "isep-notify"), "--text", short_report(f, now, err), "--fallback-ticket", ticket], capture_output=True, text=True) sys.stderr.write(p.stdout + p.stderr) return p.returncode return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))