Files
ISEP/scripts/isep-nag
T
Claude Code da9bd53cae 沒人會叫的事會自己叫+讀不完的必讀檔要被整理(inkstone/ISEP#93、inkstone/ISEP#89)
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 session 邊界。

## inkstone/ISEP#93 —— 逾期和掛著沒人接的事會主動叫

- scripts/isep-nag        撈三種沒人會叫的事(逾期 milestone/等 leo 的票/掉在地上的棒子)
- scripts/isep-notify     發 Telegram,而且**發不出去的時候不會安靜**
- hooks/overdue-nag-guard.sh  SessionStart 跑一次(不輪詢、不 fan-out、不掛 Actions)
- hooks/tests/overdue-nag.test.sh  35 條,全程離線

實跑撈得出票上點名的那五個逾期 milestone(08-24 三個、08-26 兩個)。
沒東西可報時會說「查過了,沒有」——安靜跟壞掉長得一模一樣。

🔴 工單補的那個限制(今天實測出來的)已經處理:
「能不能發得出去」取決於這個 session 載到的 ISEP 是哪一版
(prod-write-guard v0.10.0 才認得出 notify_leo 不是部署,而 hook 註冊路徑
在 session 啟動當下就寫死了)。所以 isep-notify 會**先拿真的要送的那一則
去問這個 session 註冊的那支閘**,把判定寫成檔(誰都查得到),然後:
  · 放行 ⇒ 送,並驗內層 data.data.ok(外層 200 不算送到)
  · 會擋 ⇒ **不繞路**,改貼回票上並把原文印在眼前
用 git 歷史裡的真跡(c263866 那一版閘)測過「會擋」那條路。

 實測發現通道本身現在是斷的:實例上找不到 notify_leo 工作流(404)。
   不是閘、不是網路、不是金鑰。詳情與修法寫在 docs/TESTING.md 最後一段。
   退路兩次都走通了(inkstone/ISEP#93 comment 5182、5184)。

## inkstone/ISEP#89 —— wiki 太長時有人整理

- scripts/wiki-compress   audit/plan/apply/verify/bench 五個動詞
- hooks/wiki-size-guard.sh  SessionStart 點名太長的檔;寫檔時擋「沒走流程的壓縮」
- hooks/tests/wiki-compress.test.sh  25 條,全程離線、不碰真的 wiki

設計上最重要的一條:**只搬不改**,正文一個字都不動。
「合併同類、濃縮成一行」要重寫正文,而重寫的當下沒有人會發現弄丟了什麼。
所以機器只做「搬 + 目錄 + 標 ×N/↻」,合併留給人。

拿現在的 mistakes.md 複本實壓過(不動真的 wiki):
  7,681 行 → 1,199 行
  260 條一條都沒少(verify 用內文雜湊逐條對帳)
  80 個查詢命中率 80/80,定位成本 2,956 → 131 行,**快 22.5 倍**(bench)
verify 反向測過:真的弄丟一條時它抓得到(抓不到的對帳表比沒有更糟)。

## 盤點數字

在這棵樹上當場數的,不是拿上一版加減推的:
  ls hooks/*.sh | wc -l              → 55(was 53)
  grep -c '"command":' hooks.json    → 71(was 68)
plugin.json/README/hooks-inventory 三處同步改。
**版本號沒動**(0.11.0),待總管定版。
2026-08-28 01:04:16 +00:00

394 lines
18 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <epoch> 把時鐘定住(測試用)
環境變數:
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 tokenGITEA_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/doings/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:]))