主線成員改由相依邊決定,不靠同名里程碑;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
This commit is contained in:
isep-hand
2026-09-07 10:40:55 +00:00
parent 7a9e30589c
commit d4bf172990
11 changed files with 912 additions and 170 deletions
+36 -20
View File
@@ -18,8 +18,10 @@
#
# 本閘從頭到尾**不讀派工單的任何一個字義**。它只做兩個查表:
# ① 派工單的【工單】欄位是哪一張票(`hooks/lib/dispatch_parse.py` 解結構,不判語意)
# ② 那張票所屬的 milestone 標題,等不等於被標定的主線標題(字串相等,不是模糊比對)
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 Gitea 上的事實。
# ② 那張票在不在主線上:掛在錨那一個 milestone(同 repo、同 id),或有任何一張主線成員
# 把它當相依(`/blocks`)。判準是 milestone id 與相依邊——**不是標題**:
# 別 repo 裡同名的里程碑不算(leo 2026-09-07inkstone/ISEP#133)。
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪、誰指著它」這兩個 Gitea 上的事實。
#
# ── 誤攔的出口全部先關掉(誤攔比漏擋更該修)──────────────────────────
# · **沒有主線 ⇒ 一律放行**(ISEP#82 驗收第 4 條:沒有主線不能整組壞掉)
@@ -92,16 +94,20 @@ for ref in wanted:
if v is None:
unknown.append(ref)
# ② 快取查不到的,去問那張票掛在哪。問不到 ⇒ 放行
# ② 快取查不到的,去問兩件事實:那張票掛在哪個 milestone、誰把它當相依(inkstone/ISEP#133
# 兩件都到手才敢說「不在」;問不到任何一件 ⇒ 放行。
if unknown:
fx = ML.fixture_titles()
if fx is not None: # 測試把外界定住(見 mainline.fixture_titles
learned = [r for r in unknown
if ML.norm(fx.get(r, "")) == ML.norm(ms.get("title"))]
if learned:
if fx is not None: # 測試把外界定住(見 mainline.fixture_lookup
verdicts = {}
for r in unknown:
got = ML.fixture_lookup(fx, r)
if got is not None:
verdicts[r] = ML.belongs(r, ms, ticket_milestone=got[0], blocks=got[1])
if any(v is True for v in verdicts.values()):
print("SKIP:on-mainline-fixture"); raise SystemExit
if not any(r in fx for r in unknown):
bail("unreachable") # fixture 沒收錄 ⇒ 等同問不到
if not verdicts or any(v is None for v in verdicts.values()):
bail("unreachable") # fixture 沒收錄/只知一半 ⇒ 等同問不到
else:
try:
T = load("isep_ticket", os.path.join(HERE, "..", "scripts", "ticket"))
@@ -111,28 +117,37 @@ if unknown:
# 那不是 Exception 的子類。只接 Exception 的話,這支會整個死掉而不是放行。
except BaseException:
bail("no-token") # 讀不到 ≠ 不屬於
def get(path):
req = urllib.request.Request("%s/api/v1%s" % (host, path),
headers={"Authorization": "token %s" % tok})
return json.load(urllib.request.urlopen(req, timeout=6))
reached = False
learned = []
undecided = False
for ref in unknown:
parts = ML.split_ref(ref)
if not parts:
continue
try:
owner, rest = ref.split("/", 1)
repo, num = rest.split("#", 1)
req = urllib.request.Request(
"%s/api/v1/repos/%s/%s/issues/%s" % (host, owner, repo, num),
headers={"Authorization": "token %s" % tok})
it = json.load(urllib.request.urlopen(req, timeout=6))
it = get("/repos/%s/%s/issues/%d" % parts)
reached = True
except Exception:
continue
title = ((it or {}).get("milestone") or {}).get("title") or ""
if ML.norm(title) == ML.norm(ms.get("title")):
try:
blocks = [x for x in (ML.issue_ref(b) for b in get("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)) if x]
except Exception:
blocks = None # 讀不到「誰把它當相依」⇒ 這一張判不出來
v = ML.belongs(ref, ms, ticket_milestone=(it or {}).get("milestone") or {}, blocks=blocks)
if v is True:
learned.append(ref)
elif v is None:
undecided = True
if learned:
ms["members"] = sorted(set(ms.get("members") or []) | set(learned))
ML.save(ms)
print("SKIP:on-mainline-fresh"); raise SystemExit
if not reached:
bail("unreachable") # 一張都問不到 ⇒ 放行
if not reached or undecided:
bail("unreachable") # 問不到/只知一半 ⇒ 放行
# ③ 確定都不在主線上 —— 同一組票只擋一次
import hashlib
@@ -161,7 +176,8 @@ case "$VERDICT" in
printf ' 這次要派的:%s\n\n' "$TICKETS"
printf '**先說是哪一種,再繼續**——兩種都可能是對的,錯的是默默做完:\n\n'
printf ' ① 補收 —— 它本來就該在主線上,只是沒掛上去\n'
printf ' `scripts/mainline adopt %s` 然後重送\n\n' "$FIRST"
printf ' `scripts/mainline adopt %s` 然後重送\n' "$FIRST"
printf ' (別 repo 的票會成為 hub 票的相依;**不要**去那個 repo 開同名里程碑)\n\n'
printf ' ② 跳線 —— 真的是插件事(leo 臨時交辦、擋路的地雷…)\n'
printf ' 直接重送即可(**同一張票只擋一次**),並在回覆講一句為什麼現在要岔開主線\n\n'
printf '為什麼要問:leo 2026-08-27「今天……分心」。分心的前提是有一條主線,\n'