da9bd53cae
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 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),待總管定版。
394 lines
18 KiB
Python
Executable File
394 lines
18 KiB
Python
Executable File
#!/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 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:]))
|