Files
ISEP/hooks/lib/mainline.py
T
Claude Code 21971ea83e 現在的主線是哪一個,變成一個查得到的事實(inkstone/ISEP#82)
14 個 open milestone 同時亮著,其中「Mira 現代化」同名活在 5 個 repo,
所以 SOP 說的「那個 active milestone」在現場沒有指涉對象。

- hooks/lib/mainline.py        主線的唯一存放處(一個檔放得下一條),從不打網路
- scripts/mainline             show/list/set/clear/refresh/adopt/has
- hooks/mainline-focus-guard.sh 派了不在主線上的票 ⇒ 攔一次,問補收還是跳線
- hooks/lib/countdown.py       ⏱ 那一行的主線改讀「被標定的」,蓋過「期限最近」的猜測
- hooks/countdown-guard.sh     同一個注入點加第二行 🎯(ISEP#63 那半不動)
- 測試 24 條(離線)+ countdown 原有 20 條仍全綠
2026-08-28 00:39:50 +00:00

249 lines
9.6 KiB
Python
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
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
這是 helper,不是閘(inkstone/ISEP#40 S7`lib/` 底下的東西不算一支手寫的閘)。
三個呼叫者共用它,所以「主線是誰」的答案只有一份:
· `scripts/mainline` —— 人問「現在的主線是哪一個」
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
── 為什麼要有這個檔(inkstone/ISEP#82)──────────────────────────────
2026-08-27 實查:`GET /repos/inkstone/*/milestones?state=open` 回 **14 個**
其中「Mira 現代化」這個名字同時活在 5 個 repo,還有 5 個已經逾期。
SOP 說「有 active milestone 時只做該 milestone 的事」——但**「那個」在現場沒有指涉對象**。
leo 的原話是「**14 個里程碑同時亮著卻不知道該看哪個**」。
⇒ 要解的不是「milestone 太多」(跨 repo 同名是既有做法,v0.6.0 §4SOP S7-4),
要解的是「**這一刻我在做哪一條**」沒有答案。
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
所以:
· **錨**anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
· **成員判定** 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
── 這支從不打網路 ────────────────────────────────────────────────
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
都先等一次網路。網路那一半全部在 `scripts/mainline`setrefreshadopt),
本支只讀寫 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
"""
import json
import os
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
MARKER = "🎯"
STATE_NAME = "mainline.json"
JUMP_LOG = "mainline-jumps.jsonl"
def state_dir() -> str:
"""與倒數共用同一個狀態目錄——兩行字是同一個注入點的兩半,狀態不該分家。"""
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
if not d:
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def path() -> str:
return os.path.join(state_dir(), STATE_NAME)
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
"""
try:
with open(path()) as f:
d = json.load(f)
except Exception:
return None
if not isinstance(d, dict) or not d.get("title"):
return None
return d
def save(d) -> bool:
try:
with open(path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def clear() -> bool:
try:
os.remove(path())
return True
except FileNotFoundError:
return True
except Exception:
return False
def parse_ts(raw):
if not isinstance(raw, str) or not raw.strip():
return None
s = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except Exception:
try:
dt = datetime.fromisoformat(s + "T23:59:59+08:00")
except Exception:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def due_of(ms):
return parse_ts((ms or {}).get("due_on") or "")
def norm(s) -> str:
"""比對標題用。只做「看不見的差異」的正規化——大小寫與空白。
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
"""
return "".join((s or "").split()).lower()
def ref_of(ms) -> str:
if not ms:
return ""
return "%s/%s#%s" % (ms.get("owner") or "?", ms.get("repo") or "?", ms.get("id") or "?")
def members(ms):
return set(ms.get("members") or []) if ms else set()
def belongs(ticket_ref, ms, ticket_milestone_title=None):
"""這張票屬不屬於主線?回 TrueFalseNone。
None = **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)。
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
"""
if not ms:
return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事
ref = (ticket_ref or "").strip()
if ref and ref in members(ms):
return True
if ticket_milestone_title is not None:
return norm(ticket_milestone_title) == norm(ms.get("title"))
return None
def fixture_titles():
"""測試用:把「去 Gitea 問這張票掛在哪」換成一份現成的答案。
`ISEP_MAINLINE_FIXTURE` 指向一個 JSON`{"owner/repo#N": "milestone 標題"}`。
設了它,閘就不打網路,直接拿這份當事實。
🔴 這與 `ISEP_COUNTDOWN_NOW`(把時鐘定住)是同一個性質的東西:
**把外界定住,讓閘能離線、可重複地被測**。正式環境不會設它;
沒設 ⇒ 這支回 None,判決完全走真實的 Gitea。
"""
p = os.environ.get("ISEP_MAINLINE_FIXTURE", "").strip()
if not p:
return None
try:
with open(p) as f:
d = json.load(f)
return d if isinstance(d, dict) else None
except Exception:
return None
def record_jump(ticket_ref, ms):
"""把「派了一張不在主線上的票」記一筆。**只留痕,不判罪。**
跳線本身常常是對的(補收、真的插件事)。留痕是為了讓「這條線今天被岔開幾次」
事後數得出來——沒有紀錄的話,跳線與專注在機器上長得一模一樣。
"""
try:
with open(os.path.join(state_dir(), JUMP_LOG), "a") as f:
f.write(json.dumps({
"at": datetime.now(timezone.utc).isoformat(),
"ticket": ticket_ref,
"mainline": ref_of(ms),
"mainline_title": (ms or {}).get("title"),
}, ensure_ascii=False) + "\n")
except Exception:
pass
def _human(delta: timedelta) -> str:
days = int(abs(delta).total_seconds()) // 86400
if days >= 1:
return "%d 天" % days
hours = int(abs(delta).total_seconds()) // 3600
return "%d 小時" % hours
def due_phrase(ms, now=None) -> str:
"""期限那一段。沒設期限就說沒設——不准編一個日期出來。"""
dt = due_of(ms)
if not dt:
return "沒設期限"
now = now or datetime.now(timezone.utc)
day = dt.astimezone(TAIPEI).strftime("%Y-%m-%d")
gap = dt - now
if gap.total_seconds() >= 0:
return "%s(剩 %s" % (day, _human(gap))
return "%s(🔴 已逾期 %s" % (day, _human(gap))
def progress(ms):
o = int((ms or {}).get("open_issues") or 0)
c = int((ms or {}).get("closed_issues") or 0)
total = o + c
pct = int(round(c * 100.0 / total)) if total else 0
return c, total, pct
def line(now=None) -> str:
"""注入用的那一行(倒數那一行的第二行)。**沒有主線時也要回一句話。**
ISEP#82 驗收第 4 條:沒有主線不能整組壞掉,要講得出「現在沒有主線」。
⇒ 這支永遠回一個非空字串,永遠不 raise。
"""
ms = load()
if not ms:
return ("%s 現在沒有主線——沒有任何 milestone 被標成「現在在做的那一條」。"
"要標:`scripts/mainline set <owner/repo#milestone_id>`"
"(先看有哪些:`scripts/mainline list`" % MARKER)
c, total, pct = progress(ms)
head = "%s 主線:%s%s」|%s%d%d 張已關(%d%%" % (
MARKER, ref_of(ms), ms.get("title") or "?", due_phrase(ms, now), c, total, pct)
goal = (ms.get("description") or "").strip().splitlines()
if goal:
head += "\n 目標:" + goal[0].strip()[:120]
return head
def countdown_segment(now=None):
"""給 `countdown.py` 用的 (名稱, 期限) ——讓 ⏱ 那一行說得出主線是誰。
為什麼要塞進 ⏱ 那一行:⏱ 那一行**已經有一支 Stop 閘在查它有沒有被戴上**
inkstone/ISEP#63)。主線的名字掛在那一行上,就跟著那道已經驗過的閘一起
到 leo 眼前——不必為了同一件事再立第二道會擋人的閘。
"""
ms = load()
if not ms:
return None
return (ms.get("title") or "主線", due_of(ms))