Files
ISEP/hooks/lib/mainline.py
T
isep-hand d4bf172990 主線成員改由相依邊決定,不靠同名里程碑;adopt 別 repo 的票=加相依到 hub 票;帳本落在 InkStoneCo/system-dev(inkstone/ISEP#133 → 6743)
leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個
milestone,不同 repo 用指針」。上一版 scripts/mainline 檔頭把現場的樣子(5 個同名)寫成
「既有做法」,成員也照標題跨 repo 收;總管照它做、抄進 ops-facts、還在 arcrun-rag 補建了一個同名的。

改了什麼(判準全部是 Gitea 欄位,沒有標題比對):
- hooks/lib/mainline.py:collect_members()=hub 里程碑裡的票+hub 票,沿 /dependencies 邊
  (跨 repo)收下去;belongs() 要同時拿到「掛哪個 milestone」+「誰把它當相依」才准說「不屬於」,
  只知一半回 None(閘放行);project_roots() 給主線檔與帳本共用
- scripts/mainline:set --hub <票>、adopt 別 repo ⇒ POST 相依到 hub 票(--via 可指定成員),
  沒 hub 票時擋下並給兩條走得通的路,不建同名里程碑;has 查 /blocks;
  refresh --members 才重收(真 Gitea 25 張要 19 秒,SessionStart 成本不變)
- scripts/ticket:add_dependency() 一份(subtask 與 adopt 共用,409 冪等);pick 主線組走成員清單,
  逾期組一個里程碑物件一組;claim 的主線判定改看相依;pickable(None) 給靠邊進來的票
- hooks/mainline-focus-guard.sh:新鮮查詢問 milestone+/blocks,補收出路明講不要開同名里程碑
- scripts/milestone-account:帳本找家用同一把尺,退回原始碼目錄只在它有 .git(plugin 快取沒有)

測試:新 hooks/tests/mainline-members-by-dependency.test.sh 56 條(假 Gitea 照真的回 201/409);
scripts/test-ticket-pick.sh 48→53 條(池子改用相依邊,補「同名沒邊 ⇒ 不抓」);
既有 mainline-focus-guard/repo-mirror/gitea-fallback/idle/milestone-account/debt-worklist/
handoff-writeback 全綠。真 Gitea 唯讀實跑:set --hub inkstone/InkStoneCo#44 收到 25 張、5 個 repo,
mira#6/ISEP#130/ISEP#140/arcrun-rag#104 都在,不必新增任何邊。

版本:待總管定版(改了會被載入的 hook/scripts)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 10:40:55 +00:00

574 lines
26 KiB
Python
Raw 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` —— 人問「現在的主線是哪一個」
· `scripts/ticket pickclaim` —— 雲端工人抓票:抓的是主線的成員
· `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 個里程碑同時亮著卻不知道該看哪個**」。
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
── 一條線在整個 Gitea 只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)──
🔴 本檔第一版寫的是「同名跨 repo 是既有做法,成員=票所屬 milestone 的標題與主線相同」。
那不是規則,是當時現場的樣子——而 leo 早在 2026-08-20inkstone/ISEP#30)與 08-27
inkstone/InkStoneCo#44)就定了相反的規則:**一條線一個里程碑,別 repo 的票用相依指到 hub 票**。
工具把現場當規則寫進檔頭,總管 09-07 照它做,還在 arcrun-rag 補建了一個同名的;
leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
不同 repo 用指針」。⇒ 檔頭寫的「既有做法」會被當成規則抄走,所以這裡改成規則本身:
· **錨**anchor)= `owner/repo#<milestone id>`:整個 Gitea 只有這一個 milestone 物件。
期限、進度、目標宣告都讀它。
· **hub 票**`hub_ticket`,可選)= 這條線的載體票(例:inkstone/InkStoneCo#44「這張票是那條線本身」)。
別 repo 的票要進主線,就成為它(或 hub 里程碑裡任一張票)的 **dependency**。
· **成員判定** `collect_members()`hub 里程碑裡的票 hub 票,再沿著 Gitea 的
`/dependencies` 邊(跨 repo)一路收下去。**判準是 milestone id 與相依邊,不是標題。**
同名 milestone 在別的 repo 裡開著也**不算**成員——那正是被推翻的那條。
· `belongs()` 因此需要「這張票掛在哪個 milestone」+「誰把它當相依」兩件事實才敢說「不屬於」;
只知道一半 ⇒ 回 None(不知道),閘拿到 None 一律放行。
── `load()``line()` 從不打網路 ─────────────────────────────────
它們被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
都先等一次網路。網路那一半全部在 `scripts/mainline`setrefreshadopt),
本支的讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
兩個例外都不在關鍵路徑上:`fetch_gitea()`inkstone/ISEP#140,只有 SessionStart 的
`scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來,
本檔自己不帶憑證、不認主機;`load()`/`line()` 永遠不叫它們)。
── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ───────
① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度)
② repo 那份 `InkStoneCo/system-dev/mainline.json`(隨 repo 走,inkstone/ISEP#130
③ Gitea 上 `inkstone/InkStoneCo` main 的同一個檔(inkstone/ISEP#140
①② 之間誰算數看 `set_at`(見 `load()`);③ **只在 ①② 都不存在時**被問一次,
抓到就寫進 ①,之後跟 ① 一樣對待。③ 讀的就是 ② 那個檔,只是從 Gitea 拿而不是從
工作樹拿——它不是第二套主線來源。
"""
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)
# ── repo 裡的那一份(inkstone/ISEP#130)──────────────────────────────
#
# 主線原本只住在 `~/.claude/isep-countdown/mainline.json`——**這台機器的家目錄**。
# 雲端 session 是另一台機器:家目錄是空的,clone 下來的 repo 才是它唯一拿得到的東西。
# 2026-09-05 leo:「routine 每天早上開啟後抓不到任務」——不是 Gitea 沒票,
# 是雲端根本不知道主線是哪一條。
# ⇒ `inkstone/InkStoneCo` 的 `system-dev/mainline.json` 是**隨 repo 走的那一份**
# PR inkstone/InkStoneCo#118 放進去的)。這裡的規矩:
# · 讀:家目錄那份沒有 ⇒ 讀 repo 那份(雲端就是這個形狀)。
# 兩份都在 ⇒ 用 `set_at` 較新的(誰後標的誰算數,不分本機雲端)。
# · 寫:`set``adopt``clear` 這種「主線是誰」的改動 ⇒ 兩份一起寫(`mirror=True`)。
# `refresh`/閘順手學到成員 ⇒ 只寫家目錄那份——否則每開一個 session
# InkStoneCo 的工作樹就髒一次,而髒的 diff 沒有人會去 commit。
# · repo 那份要 commit/開 PR 才到得了雲端;這支不碰 git。
REPO_REL = os.path.join("system-dev", STATE_NAME)
def project_roots():
"""「這個 session 的專案在哪」的候選根,依序:`$CLAUDE_PROJECT_DIR`、它底下的 `InkStoneCo/`
(雲端薄殼的形狀:專案根是薄殼、真身在 InkStoneCo/)、cwd、cwd 底下的 `InkStoneCo/`。
主線檔(本檔)與 `scripts/milestone-account` 的帳本(inkstone/ISEP#133)都靠它找
`system-dev/`——兩份「隨 repo 走的狀態」用同一把尺找家,不各寫一套。"""
roots = []
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
if r:
for c in (r, os.path.join(r, "InkStoneCo")):
if c not in roots:
roots.append(c)
return roots
def repo_candidates():
"""repo 那一份可能在哪:明指 > 專案根 > 專案根底下的 InkStoneCo/(雲端薄殼的形狀)> cwd 同兩層。"""
out = []
forced = os.environ.get("ISEP_MAINLINE_REPO_FILE", "").strip()
if forced == "none":
return [] # 測試用:把「repo 那份」整個拿掉,外界定住
if forced:
out.append(forced)
for r in project_roots():
p = os.path.join(r, REPO_REL)
if p not in out:
out.append(p)
return out
def repo_path(for_write=False):
"""讀:第一個**存在**的檔。寫:第一個 `system-dev/` 目錄存在的位置(檔可以還沒有)。
找不到 ⇒ None(雲端沒 clone InkStoneCo、或 cwd 在別的 repo 時都是正常狀態)。"""
for p in repo_candidates():
if for_write:
if os.path.isdir(os.path.dirname(p)):
return p
elif os.path.isfile(p):
return p
return None
def _valid(d):
"""一份主線檔長什麼樣才算數:dict、而且有 title。三個來源用同一把尺。"""
return d if (isinstance(d, dict) and d.get("title")) else None
def _read(p):
try:
with open(p) as f:
d = json.load(f)
except Exception:
return None
return _valid(d)
# ── Gitea main 上的那一份(inkstone/ISEP#140)────────────────────────
#
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:那時
# `InkStoneCo/` 還沒 clone,家目錄又是空的 ⇒ 上面兩份都不存在 ⇒ `countdown.py` 退到
# 第二基準「期限最近的 milestone」⇒ 第一則 prompt 印出一條兩週前的舊線
# (票上實測:`🔴 主線 把管理這條線做對 已逾期 341 小時`+`🎯 現在沒有主線`)。
# 機制沒壞,是順序。而「哪一條是主線」這個指標本來就在 Gitea 上讀得到。
#
# 規矩(跟上面「repo 裡的那一份」那段接起來,不另立一套):
# · 只在家目錄與 repo 那份**都不存在**時問 Gitea,問一次;任一份在場就不問。
# · 只有 SessionStart 的 `scripts/mainline refresh` 會叫 `fetch_gitea()`;抓到就
# `save()` 進家目錄(不 mirror:雲端沒有 InkStoneCo/ 可寫,本機也不弄髒工作樹)。
# bootstrap 之後 repo 那份出現時,兩份 `set_at` 相同 ⇒ 家目錄那份算數(它有 refresh 過的進度)。
# · 匿名優先。匿名被拒(401/403/404)且環境拿得到 Gitea token 才帶 token 再問一次
# ——`inkstone/InkStoneCo` 在 Gitea 是 **private**2026-09-07 實測:匿名打 raw
# contentsrepo API 都 404),沒 token 就真的讀不到。
# · 讀不到(斷網/5xx/不是 JSON/沒 title/逾時)⇒ 回 None,退回現行行為,不報錯。
# 逾時有上限(`ISEP_MAINLINE_GITEA_TIMEOUT`,預設 4 秒,最多問兩次)。
# · 測試用:`ISEP_MAINLINE_GITEA_URL=none` 把這個來源整個拿掉;`TICKET_HOST` 換主機
# (既有測試都把它指到連不上的位址,所以它們照舊離線)。
GITEA_HOST_DEFAULT = "https://git.uncle6.me"
GITEA_REPO = "inkstone/InkStoneCo"
GITEA_REF = "main"
def gitea_url() -> str:
forced = os.environ.get("ISEP_MAINLINE_GITEA_URL", "").strip()
if forced == "none":
return ""
if forced:
return forced
host = (os.environ.get("TICKET_HOST", "").strip() or GITEA_HOST_DEFAULT).rstrip("/")
return "%s/api/v1/repos/%s/raw/%s?ref=%s" % (
host, GITEA_REPO, REPO_REL.replace(os.sep, "/"), GITEA_REF)
def fetch_gitea(token="", timeout=None):
"""去 Gitea 讀 `inkstone/InkStoneCo` main 上的 `system-dev/mainline.json`。
回一份跟 `_read()` 同樣通過 `_valid()` 的 dict,或 None。**永遠不 raise。**
`token`:字串,或一個回字串的函式(只在匿名被拒時才會被叫)。
🔴 這是本檔唯一會打網路的函式,只准 SessionStart 的 `scripts/mainline refresh` 叫;
`load()``line()` 走的是每一則訊息的關鍵路徑,不准碰它。
"""
url = gitea_url()
if not url:
return None
if timeout is None:
try:
timeout = float(os.environ.get("ISEP_MAINLINE_GITEA_TIMEOUT", "") or 4)
except Exception:
timeout = 4.0
import urllib.error
import urllib.request
def get(headers):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
try:
text = get({}) # 匿名優先(D20 的形狀:讀不帶憑證)
except urllib.error.HTTPError as e:
if e.code not in (401, 403, 404):
return None
# `token` 可以是字串,也可以是「被拒了才去拿」的函式——拿 token 可能要掃 git remote、
# 可能拿不到而想開口抱怨,匿名讀得到的時候根本不該走到那裡。
tok = token() if callable(token) else token
if not tok:
return None
try:
text = get({"Authorization": "token " + tok}) # private repo:帶 token 再問一次
except Exception:
return None
except Exception:
return None
try:
return _valid(json.loads(text))
except Exception:
return None
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
家目錄那份與 repo 那份都在時,`set_at` 較新的算數(格式固定 `YYYY-MM-DD HH:MM`
字串比大小就是時間比大小);一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)。
"""
local = _read(path())
rp = repo_path()
remote = _read(rp) if rp else None
if local and remote:
return remote if (remote.get("set_at") or "") > (local.get("set_at") or "") else local
return local or remote
def save(d, mirror=False) -> bool:
"""寫家目錄那份;`mirror=True` 時同時寫 repo 那份(只有「主線是誰」變了才這樣叫)。
回 True/False 只看家目錄那份——repo 那份寫不寫得進去用 `repo_path(for_write=True)` 另外問。"""
try:
with open(path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
except Exception:
return False
if mirror:
rp = repo_path(for_write=True)
if rp:
try:
with open(rp, "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception:
pass
return True
def clear(mirror=False) -> bool:
ok = True
try:
os.remove(path())
except FileNotFoundError:
pass
except Exception:
ok = False
if mirror:
rp = repo_path()
if rp:
try:
os.remove(rp)
except Exception:
ok = False
return ok
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:
"""比對標題用。只做「看不見的差異」的正規化——大小寫與空白。
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
inkstone/ISEP#133 起標題只用來**查**Gitea 的 `milestones=` 只吃名字),
成員判定看的是 milestone id 與相依邊,不再拿標題當判準。
"""
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 split_ref(ref):
"""`owner/repo#N` → (owner, repo, N);不是這個形狀 ⇒ None。"""
import re
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
return (m.group(1), m.group(2), int(m.group(3))) if m else None
def issue_ref(issue, owner=None, repo=None):
"""Gitea 的 issue dict → `owner/repo#N`。相依端點回的 issue 帶 `repository.full_name`
列 milestone 的端點有時不帶 ⇒ 呼叫端補 owner/repo。"""
if not isinstance(issue, dict) or not issue.get("number"):
return None
full = ((issue.get("repository") or {}).get("full_name")) or (
"%s/%s" % (owner, repo) if owner and repo else None)
return "%s#%s" % (full, issue["number"]) if full else None
def hub_ticket(ms):
return ((ms or {}).get("hub_ticket") or "").strip() or None
def members(ms):
out = set(ms.get("members") or []) if ms else set()
h = hub_ticket(ms)
if h:
out.add(h)
return out
MEMBER_WALK_CAP = 300
def collect_members(api, ms, cap=MEMBER_WALK_CAP):
"""主線的成員(inkstone/ISEP#133):**hub 里程碑裡的票 hub 票,沿 `/dependencies` 收下去**。
`api(path)` 由呼叫端提供(`scripts/ticket` 的 `api``api_soft`):回解析好的 JSON 或 None。
本檔不帶憑證、不認主機——這樣 `scripts/ticket` 的離線測試用假池子就測得到整條路。
回 (members, info)
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
"reached": bool, "capped": bool}
🔴 `reached=False` 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
(讀不到 ≠ 沒有成員)。
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
"""
import urllib.parse
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
title = ms.get("title") or ""
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
"reached": False, "capped": False}
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q))
if not isinstance(rows, list):
return [], info
info["reached"] = True
roots = []
for it in rows:
if not isinstance(it, dict) or it.get("pull_request"):
continue
im = it.get("milestone") or {}
# 🔴 `milestones=` 在不同 Gitea 版本對「名稱 vs id」的解讀不一致,拿回來要自己用 id 確認
if str(im.get("id")) != str(mid) and norm(im.get("title")) != norm(title):
continue
r = issue_ref(it, owner, repo)
if r and r not in roots:
roots.append(r)
info["milestone"] = list(roots)
h = hub_ticket(ms)
if h and h not in roots:
roots.append(h)
seen, queue, out = set(roots), list(roots), list(roots)
while queue:
cur = queue.pop(0)
parts = split_ref(cur)
if not parts:
continue
deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
if not isinstance(deps, list):
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事
for d in deps:
r = issue_ref(d)
if not r or r in seen:
continue
if len(out) >= cap:
info["capped"] = True
break
seen.add(r); out.append(r); queue.append(r)
info["via_deps"].append(r)
return sorted(set(out)), info
def _ms_matches(ticket_milestone, ms):
"""票所掛的 milestone 是不是錨那一個。dict 看 id(沒 id 才退回標題);str 只有標題。"""
if isinstance(ticket_milestone, dict):
if ticket_milestone.get("id") is not None and ms.get("id") is not None:
return str(ticket_milestone.get("id")) == str(ms.get("id"))
return norm(ticket_milestone.get("title")) == norm(ms.get("title"))
return norm(ticket_milestone) == norm(ms.get("title"))
def belongs(ticket_ref, ms, ticket_milestone=None, blocks=None):
"""這張票屬不屬於主線?回 TrueFalseNone。
None = **不知道**。🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
inkstone/ISEP#133 起要說「不屬於」需要兩件事實都在手上:
ticket_milestone 票掛在哪個 milestonedict 或標題字串;None=沒查)
blocks 誰把這張票當相依(`/issues/N/blocks` 回的 ref 清單;None=沒查)
· 在快取成員裡/就是 hub 票 ⇒ True
· 掛在錨那一個 milestone(同 repo、同 id ⇒ True
· blocks 裡有任何一張是成員或 hub 票 ⇒ True
· milestone 與 blocks 都查過、都不中 ⇒ False
· 只查了其中一半而那一半不中 ⇒ None(別 repo 的票只看 milestone 判不出來)
別 repo 裡同名的 milestone **不算**——那是被 leo 推翻的判準(本檔檔頭)。
"""
if not ms:
return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事
ref = (ticket_ref or "").strip()
if ref and ref in members(ms):
return True
parts = split_ref(ref)
same_repo = bool(parts) and parts[0] == ms.get("owner") and parts[1] == ms.get("repo")
if ticket_milestone is not None and ticket_milestone != "" and same_repo and _ms_matches(ticket_milestone, ms):
return True
if blocks is not None:
mem = members(ms)
if any((b or "").strip() in mem for b in blocks):
return True
if ticket_milestone is not None and blocks is not None:
return False
return None
def fixture_lookup(fx, ref):
"""把 `ISEP_MAINLINE_FIXTURE` 裡一筆整理成 (ticket_milestone, blocks)。
值是字串 ⇒ 那是它掛的 milestone 標題、而且**沒有人把它當相依**(blocks=[])——
舊測資的形狀,兩件事實都給齊了,判得出 True/False。
值是 dict ⇒ `{"milestone": {...}|"標題", "blocks": ["owner/repo#N", …]}`。
沒收錄 ⇒ None(等同問不到)。"""
if not isinstance(fx, dict) or ref not in fx:
return None
v = fx[ref]
if isinstance(v, dict):
return v.get("milestone"), list(v.get("blocks") or [])
return v, []
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))