主線成員改由相依邊決定,不靠同名里程碑;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:
+174
-24
@@ -2,8 +2,9 @@
|
||||
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
|
||||
|
||||
這是 helper,不是閘(inkstone/ISEP#40 S7:`lib/` 底下的東西不算一支手寫的閘)。
|
||||
三個呼叫者共用它,所以「主線是誰」的答案只有一份:
|
||||
四個呼叫者共用它,所以「主線是誰」「誰在主線上」的答案只有一份:
|
||||
· `scripts/mainline` —— 人問「現在的主線是哪一個」
|
||||
· `scripts/ticket pick/claim` —— 雲端工人抓票:抓的是主線的成員
|
||||
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
|
||||
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
|
||||
|
||||
@@ -13,27 +14,35 @@
|
||||
SOP 說「有 active milestone 時只做該 milestone 的事」——但**「那個」在現場沒有指涉對象**。
|
||||
|
||||
leo 的原話是「**14 個里程碑同時亮著卻不知道該看哪個**」。
|
||||
⇒ 要解的不是「milestone 太多」(跨 repo 同名是既有做法,v0.6.0 §4/SOP S7-4),
|
||||
要解的是「**這一刻我在做哪一條**」沒有答案。
|
||||
|
||||
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
|
||||
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
|
||||
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
|
||||
|
||||
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
|
||||
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
|
||||
所以:
|
||||
· **錨**(anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
|
||||
· **成員判定**= 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
|
||||
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
|
||||
── 一條線在整個 Gitea 只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)──
|
||||
🔴 本檔第一版寫的是「同名跨 repo 是既有做法,成員=票所屬 milestone 的標題與主線相同」。
|
||||
那不是規則,是當時現場的樣子——而 leo 早在 2026-08-20(inkstone/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`(set/refresh/adopt),
|
||||
本支的讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
|
||||
唯一的例外是 `fetch_gitea()`(inkstone/ISEP#140,見下方第三個來源那段):
|
||||
它會打一次 Gitea,但**只有 SessionStart 的 `scripts/mainline refresh` 叫它**,
|
||||
`load()`/`line()` 永遠不叫——所以每一則訊息的路徑上仍然沒有網路。
|
||||
兩個例外都不在關鍵路徑上:`fetch_gitea()`(inkstone/ISEP#140,只有 SessionStart 的
|
||||
`scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來,
|
||||
本檔自己不帶憑證、不認主機;`load()`/`line()` 永遠不叫它們)。
|
||||
|
||||
── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ───────
|
||||
① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度)
|
||||
@@ -86,6 +95,21 @@ def path() -> str:
|
||||
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 = []
|
||||
@@ -94,11 +118,7 @@ def repo_candidates():
|
||||
return [] # 測試用:把「repo 那份」整個拿掉,外界定住
|
||||
if forced:
|
||||
out.append(forced)
|
||||
roots = []
|
||||
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
|
||||
if r:
|
||||
roots += [r, os.path.join(r, "InkStoneCo")]
|
||||
for r in roots:
|
||||
for r in project_roots():
|
||||
p = os.path.join(r, REPO_REL)
|
||||
if p not in out:
|
||||
out.append(p)
|
||||
@@ -291,6 +311,8 @@ def norm(s) -> str:
|
||||
|
||||
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
|
||||
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
|
||||
inkstone/ISEP#133 起標題只用來**查**(Gitea 的 `milestones=` 只吃名字),
|
||||
成員判定看的是 milestone id 與相依邊,不再拿標題當判準。
|
||||
"""
|
||||
return "".join((s or "").split()).lower()
|
||||
|
||||
@@ -301,27 +323,155 @@ def ref_of(ms) -> str:
|
||||
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):
|
||||
return set(ms.get("members") or []) if ms else set()
|
||||
out = set(ms.get("members") or []) if ms else set()
|
||||
h = hub_ticket(ms)
|
||||
if h:
|
||||
out.add(h)
|
||||
return out
|
||||
|
||||
|
||||
def belongs(ticket_ref, ms, ticket_milestone_title=None):
|
||||
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):
|
||||
"""這張票屬不屬於主線?回 True/False/None。
|
||||
|
||||
None = **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)。
|
||||
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
|
||||
None = **不知道**。🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
|
||||
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
|
||||
|
||||
inkstone/ISEP#133 起要說「不屬於」需要兩件事實都在手上:
|
||||
ticket_milestone 票掛在哪個 milestone(dict 或標題字串;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
|
||||
if ticket_milestone_title is not None:
|
||||
return norm(ticket_milestone_title) == norm(ms.get("title"))
|
||||
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 問這張票掛在哪」換成一份現成的答案。
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
#
|
||||
# 本閘從頭到尾**不讀派工單的任何一個字義**。它只做兩個查表:
|
||||
# ① 派工單的【工單】欄位是哪一張票(`hooks/lib/dispatch_parse.py` 解結構,不判語意)
|
||||
# ② 那張票所屬的 milestone 標題,等不等於被標定的主線標題(字串相等,不是模糊比對)
|
||||
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 Gitea 上的事實。
|
||||
# ② 那張票在不在主線上:掛在錨那一個 milestone(同 repo、同 id),或有任何一張主線成員
|
||||
# 把它當相依(`/blocks`)。判準是 milestone id 與相依邊——**不是標題**:
|
||||
# 別 repo 裡同名的里程碑不算(leo 2026-09-07,inkstone/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'
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env bash
|
||||
# 主線成員「由相依邊決定,不靠同名里程碑」的迴歸測試(inkstone/ISEP#133 → comment 6743)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
|
||||
# 不同 repo 用指針」。上一版 `scripts/mainline` 的成員=「票所屬 milestone 的標題與主線相同(不分 repo)」,
|
||||
# 總管照它做、還在 arcrun-rag 補建了一個同名的。這裡驗票上的三條驗法:
|
||||
# A 成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo);別 repo 同名里程碑裡的票**不算**
|
||||
# B `adopt` 別 repo 的票 ⇒ 在 hub 票上加相依;**不會**在那個 repo 建同名里程碑;沒有 hub 票時
|
||||
# 擋下並給兩條走得通的路(那條路真的跑一次)
|
||||
# C 三個同名里程碑關掉之後,`mainline show`/`ticket pick`/`has`/focus guard 仍然列得出跨 repo 成員
|
||||
# D `refresh` 讀不到 Gitea ⇒ 成員保留(讀不到 ≠ 沒有成員)
|
||||
# E `milestone-account` 的帳本落在 InkStoneCo/system-dev/,不落在 plugin 快取
|
||||
#
|
||||
# 用法:bash hooks/tests/mainline-members-by-dependency.test.sh
|
||||
# 🔴 全程離線:Gitea 是本機一個假伺服器(TICKET_HOST 指過去),它照真 Gitea 回:相依 POST 201、
|
||||
# 已存在 409、issue 列表用 `milestones=` 名字查。狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走
|
||||
# TMP 底下的假薄殼、HOME 也指到 TMP。不碰真家目錄、不碰真 Gitea、不碰真 InkStoneCo。
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAINLINE="$ROOT/../scripts/mainline"
|
||||
TICKET="$ROOT/../scripts/ticket"
|
||||
ACCOUNT="$ROOT/../scripts/milestone-account"
|
||||
GUARD="$ROOT/mainline-focus-guard.sh"
|
||||
TMP=$(mktemp -d)
|
||||
export HOME="$TMP/home"; mkdir -p "$HOME"
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export ISEP_COUNTDOWN_NOW=1757203200 # 2026-09-07 00:00 UTC
|
||||
export ISEP_MAINLINE_GITEA_URL=none # 第三來源(ISEP#140)不在這支的範圍
|
||||
export GITEA_TOKEN_CLAUDE_CODE=fake-token-for-test
|
||||
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE ISEP_MAINLINE_FILE CLAUDE_CODE_CHILD_SESSION
|
||||
SHELL_ROOT="$TMP/shell"; HUB="$SHELL_ROOT/InkStoneCo"; mkdir -p "$HUB/system-dev" # 雲端薄殼的形狀
|
||||
export CLAUDE_PROJECT_DIR="$SHELL_ROOT"
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ── 假 Gitea:資料在 $TMP/world.json(測試中途可以改),每個請求記一行 `VERB path` 到 $TMP/log ──
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, os, re, sys, urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
W = os.path.join(TMP, "world.json")
|
||||
def world(): return json.load(open(W))
|
||||
def save(w): json.dump(w, open(W, "w"), ensure_ascii=False)
|
||||
def ref(r): return r
|
||||
def issue_obj(w, r):
|
||||
o, rest = r.split("/", 1); repo, n = rest.split("#")
|
||||
d = w["issues"][r]
|
||||
ms = d.get("milestone")
|
||||
return {"number": int(n), "title": d.get("title", r), "state": d.get("state", "open"),
|
||||
"labels": [{"name": l} for l in d.get("labels", [])],
|
||||
"assignees": [{"login": a} for a in d.get("assignees", [])],
|
||||
"milestone": (w["milestones"][ms] | {"id": int(ms.split("#")[1])}) if ms else None,
|
||||
"created_at": "2026-09-01T00:00:00Z",
|
||||
"repository": {"full_name": "%s/%s" % (o, repo)}}
|
||||
def ms_obj(w, key):
|
||||
o, rest = key.split("/", 1); repo, mid = rest.split("#")
|
||||
return w["milestones"][key] | {"id": int(mid)}
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def _send(self, code, body):
|
||||
raw = json.dumps(body, ensure_ascii=False).encode()
|
||||
self.send_response(code); self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
|
||||
def _log(self):
|
||||
with open(os.path.join(TMP, "log"), "a") as f: f.write("%s %s\n" % (self.command, self.path))
|
||||
def do_GET(self):
|
||||
self._log(); w = world()
|
||||
if os.path.exists(os.path.join(TMP, "down")): return self._send(500, {"message": "boom"})
|
||||
p, _, qs = self.path.partition("?"); q = urllib.parse.parse_qs(qs)
|
||||
if p == "/api/v1/orgs/inkstone/repos": return self._send(200, [{"name": r} for r in w["repos"]])
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones/(\d+)$", p)
|
||||
if m:
|
||||
key = "%s/%s#%s" % m.groups()
|
||||
return self._send(200, ms_obj(w, key)) if key in w["milestones"] else self._send(404, {"message": "not found"})
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones$", p)
|
||||
if m:
|
||||
pre = "%s/%s#" % m.groups(); st = q.get("state", ["open"])[0]
|
||||
return self._send(200, [ms_obj(w, k) for k in w["milestones"] if k.startswith(pre)
|
||||
and (st == "all" or w["milestones"][k]["state"] == st)])
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues$", p)
|
||||
if m:
|
||||
pre = "%s/%s#" % m.groups(); want = q.get("milestones", [""])[0]; st = q.get("state", ["open"])[0]
|
||||
out = []
|
||||
for r, d in w["issues"].items():
|
||||
if not r.startswith(pre): continue
|
||||
if st != "all" and d.get("state", "open") != st: continue
|
||||
msk = d.get("milestone")
|
||||
if want and (not msk or w["milestones"][msk]["title"] != want): continue
|
||||
out.append(issue_obj(w, r))
|
||||
return self._send(200, out)
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)(/dependencies|/blocks)?$", p)
|
||||
if m:
|
||||
r = "%s/%s#%s" % m.groups()[:3]
|
||||
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
||||
if not m.group(4): return self._send(200, issue_obj(w, r))
|
||||
if m.group(4) == "/dependencies": return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
||||
return self._send(404, {"message": "not found", "path": p})
|
||||
def do_POST(self):
|
||||
self._log(); w = world()
|
||||
n = int(self.headers.get("Content-Length") or 0); body = json.loads(self.rfile.read(n) or b"{}")
|
||||
p = self.path.partition("?")[0]
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/dependencies$", p)
|
||||
if m:
|
||||
parent = "%s/%s#%s" % m.groups(); child = "%s/%s#%s" % (body["owner"], body["repo"], body["index"])
|
||||
if child in w["deps"].get(parent, []): return self._send(409, {"message": "dependency already exists"})
|
||||
w["deps"].setdefault(parent, []).append(child); save(w)
|
||||
return self._send(201, issue_obj(w, parent))
|
||||
if re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones$", p):
|
||||
w.setdefault("created_milestones", []).append(p); save(w) # 🔴 這條不該被打到
|
||||
return self._send(201, {"id": 999, "title": body.get("title")})
|
||||
return self._send(404, {"message": "not found"})
|
||||
def do_PATCH(self):
|
||||
self._log(); w = world()
|
||||
n = int(self.headers.get("Content-Length") or 0); body = json.loads(self.rfile.read(n) or b"{}")
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)$", self.path.partition("?")[0])
|
||||
if m and "milestone" in body:
|
||||
r = "%s/%s#%s" % m.groups(); key = "%s/%s#%s" % (m.group(1), m.group(2), body["milestone"])
|
||||
w["issues"][r]["milestone"] = key; save(w)
|
||||
return self._send(201, issue_obj(w, r))
|
||||
return self._send(404, {"message": "not found"})
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H); srv.daemon_threads = True; srv.serve_forever()
|
||||
PY
|
||||
|
||||
# 世界:一條線「AI 問一次就看得到全部」,錨在 Arcrun#48;三個 repo 各有一個同名的(09-07 現場的形狀)
|
||||
python3 - "$TMP/world.json" <<'PY'
|
||||
import json, sys
|
||||
T = "AI 問一次就看得到全部"
|
||||
w = {
|
||||
"repos": ["Arcrun", "mira", "ISEP", "arcrun-rag", "InkStoneCo"],
|
||||
"milestones": {
|
||||
"inkstone/Arcrun#48": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 3, "closed_issues": 0, "description": "兩個目錄讓 AI 查詢"},
|
||||
"inkstone/Arcrun#50": {"title": "AI 問得到內文", "state": "open", "due_on": "2026-08-26T23:59:59Z", "open_issues": 1, "closed_issues": 1},
|
||||
"inkstone/mira#49": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 1, "closed_issues": 0},
|
||||
"inkstone/ISEP#60": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 1, "closed_issues": 1},
|
||||
"inkstone/arcrun-rag#61": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 2, "closed_issues": 0},
|
||||
},
|
||||
"issues": {
|
||||
"inkstone/Arcrun#86": {"milestone": "inkstone/Arcrun#48", "labels": ["p/high", "s/doing"]},
|
||||
"inkstone/Arcrun#176": {"milestone": "inkstone/Arcrun#48", "labels": ["s/todo"]},
|
||||
"inkstone/Arcrun#197": {"milestone": None, "labels": ["s/todo"]},
|
||||
"inkstone/Arcrun#50": {"milestone": "inkstone/Arcrun#50", "labels": ["s/todo"]},
|
||||
"inkstone/mira#6": {"milestone": "inkstone/mira#49", "labels": ["s/todo", "s/doing"]},
|
||||
"inkstone/ISEP#130": {"milestone": "inkstone/ISEP#60", "labels": ["Human", "p/high", "s/todo"]},
|
||||
"inkstone/ISEP#140": {"milestone": None, "labels": ["s/todo"]},
|
||||
"inkstone/arcrun-rag#104": {"milestone": "inkstone/arcrun-rag#61", "labels": ["s/review"], "assignees": ["claude-code"]},
|
||||
"inkstone/arcrun-rag#37": {"milestone": "inkstone/arcrun-rag#61", "labels": ["s/todo"]},
|
||||
"inkstone/InkStoneCo#44": {"milestone": None, "labels": ["s/doing"]},
|
||||
},
|
||||
"deps": {
|
||||
"inkstone/Arcrun#86": ["inkstone/mira#6"], # 09-07 現場:Arcrun#86 已經有跨 repo 相依
|
||||
"inkstone/ISEP#130": ["inkstone/ISEP#140"], # 09-07 現場:#130 等 #140
|
||||
"inkstone/InkStoneCo#44": ["inkstone/arcrun-rag#104"],# 09-07 現場:#44「這張票是那條線本身」
|
||||
},
|
||||
}
|
||||
json.dump(w, open(sys.argv[1], "w"), ensure_ascii=False, indent=1)
|
||||
PY
|
||||
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||
: > "$TMP/log"
|
||||
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||
export TICKET_HOST="http://127.0.0.1:$PORT"
|
||||
|
||||
ml(){ python3 "$MAINLINE" "$@" 2>&1; }
|
||||
members(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(' '.join(sorted(d.get('members') or [])))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json"; }
|
||||
world_set(){ python3 - "$TMP/world.json" "$@" <<'PY'
|
||||
import json, sys
|
||||
w = json.load(open(sys.argv[1])); code = sys.argv[2]
|
||||
exec(code)
|
||||
json.dump(w, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
}
|
||||
created_ms(){ python3 -c "import json,sys; print(len(json.load(open(sys.argv[1])).get('created_milestones') or []))" "$TMP/world.json"; }
|
||||
dispatch(){ # 派工單 → "exit|stderr"
|
||||
out=$(python3 -c 'import json,sys;print(json.dumps({"tool_input":{"prompt":sys.argv[1]},"session_id":"t"}))' "$1" \
|
||||
| bash "$GUARD" 2>&1 >/dev/null); rc=$?
|
||||
printf '%s|%s' "$rc" "$out"
|
||||
}
|
||||
|
||||
echo "── A. set:成員=hub 里程碑裡的票 + hub 票 + 它們的相依;同名不算 ──"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" = 0 ] && ok "① set --hub 離開碼 0" || no "① set 失敗" "$OUT"
|
||||
M=$(members)
|
||||
if [[ " $M " == *" inkstone/Arcrun#86 "* && " $M " == *" inkstone/Arcrun#176 "* ]]; then ok "② hub 里程碑裡的兩張是成員(Arcrun#86、#176)"; else no "② 里程碑裡的票沒收到" "$M"; fi
|
||||
case "$M" in *"inkstone/mira#6"*) ok "③ mira#6 是成員——因為 Arcrun#86 把它當相依(跨 repo 邊)" ;; *) no "③ 相依沒走到 mira#6" "$M" ;; esac
|
||||
case "$M" in *"inkstone/arcrun-rag#104"*) ok "④ arcrun-rag#104 是成員——因為 hub 票 InkStoneCo#44 把它當相依" ;; *) no "④ hub 票的相依沒收到" "$M" ;; esac
|
||||
case "$M" in *"inkstone/InkStoneCo#44"*) ok "⑤ hub 票自己也是成員" ;; *) no "⑤ hub 票不在成員裡" "$M" ;; esac
|
||||
case "$M" in *"inkstone/ISEP#130"*) no "⑥ ISEP#130 掛在 ISEP 的同名里程碑、沒有任何邊 ⇒ 不該是成員" "$M" ;; *) ok "⑥ ISEP#130 掛在 ISEP 裡**同名**的里程碑但沒有邊 ⇒ 不是成員(同名不算,leo 09-07)" ;; esac
|
||||
case "$M" in *"inkstone/arcrun-rag#37"*) no "⑦ arcrun-rag#37 同名沒邊 ⇒ 不該是成員" "$M" ;; *) ok "⑦ arcrun-rag#37(arcrun-rag 同名里程碑裡、沒邊)也不是" ;; esac
|
||||
case "$M" in *"inkstone/Arcrun#50"*) no "⑧ 別條線的票混進來了" "$M" ;; *) ok "⑧ 別條線(Arcrun#50)不在成員裡" ;; esac
|
||||
grep -q '"hub_ticket": "inkstone/InkStoneCo#44"' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "⑨ hub 票寫進 state(之後 adopt 不必再指定)" || no "⑨ hub_ticket 沒寫" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
grep -q '"hub_ticket": "inkstone/InkStoneCo#44"' "$HUB/system-dev/mainline.json" && ok "⑩ repo 那份(InkStoneCo/system-dev)也一起寫了(ISEP#130 的鏡像沒被弄壞)" || no "⑩ 鏡像沒寫" "$(ls "$HUB/system-dev")"
|
||||
grep -qE 'GET /api/v1/repos/inkstone/(mira|ISEP|arcrun-rag)/issues\?' "$TMP/log" && no "⑪ set 去別的 repo 列同名里程碑的票了" "$(grep issues? "$TMP/log")" || ok "⑪ set 只列 hub 里程碑那一個 repo 的票,不去別的 repo 找同名"
|
||||
OUT=$(ml)
|
||||
case "$OUT" in *"hub 票:inkstone/InkStoneCo#44"*) ok "⑫ show 講得出 hub 票是誰" ;; *) no "⑫ show 沒印 hub 票" "$OUT" ;; esac
|
||||
case "$OUT" in *"Arcrun 2"*"InkStoneCo 1"*"arcrun-rag 1"*"mira 1"*) ok "⑬ show 的成員分佈跨四個 repo(沒有同名里程碑也列得出)" ;; *) no "⑬ show 分佈不對" "$OUT" ;; esac
|
||||
|
||||
echo "── B. adopt:別 repo ⇒ 加相依到 hub 票;不建同名里程碑;沒 hub 票時給走得通的路 ──"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/ISEP#130 --via inkstone/Arcrun#86); RC=$?
|
||||
[ "$RC" = 0 ] && ok "⑭ adopt 別 repo 的票(--via 指到 hub 里程碑裡的一張)離開碼 0" || no "⑭ adopt 失敗" "$OUT"
|
||||
grep -q '^POST /api/v1/repos/inkstone/Arcrun/issues/86/dependencies' "$TMP/log" && ok "⑮ 動作是 POST 相依到 Arcrun#86(Gitea 原生邊)" || no "⑮ 沒有 POST 相依" "$(cat "$TMP/log")"
|
||||
grep -q '^PATCH' "$TMP/log" && no "⑯ 別 repo 的票不該被改 milestone" "$(grep PATCH "$TMP/log")" || ok "⑯ 沒有去改那張票的 milestone(別 repo 的票不掛 hub 里程碑)"
|
||||
[ "$(created_ms)" = 0 ] && ok "⑰ 沒有在 ISEP 建任何里程碑(同名里程碑不是出路)" || no "⑰ 建了里程碑" "$(created_ms)"
|
||||
M=$(members)
|
||||
case "$M" in *"inkstone/ISEP#130"*"inkstone/ISEP#140"*) ok "⑱ 成員多了 ISEP#130,**還有它的相依 ISEP#140**(邊會一路走下去)" ;; *) no "⑱ 成員沒更新到 #130/#140" "$M" ;; esac
|
||||
case "$OUT" in *"沒有多出任何里程碑"*) ok "⑲ 訊息明講那個 repo 沒有多出里程碑" ;; *) no "⑲ 訊息沒講" "$OUT" ;; esac
|
||||
OUT=$(ml adopt inkstone/ISEP#130 --via inkstone/Arcrun#86); RC=$?
|
||||
[ "$RC" = 0 ] && ok "⑳ 同一張再 adopt 一次(Gitea 回 409)⇒ 仍是離開碼 0(冪等,不炸)" || no "⑳ 重複 adopt 炸了" "$OUT"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/arcrun-rag#37); RC=$?
|
||||
[ "$RC" = 0 ] && grep -q '^POST /api/v1/repos/inkstone/InkStoneCo/issues/44/dependencies' "$TMP/log" \
|
||||
&& ok "㉑ 沒給 --via ⇒ 用 state 裡的 hub 票(InkStoneCo#44)" || no "㉑ 沒用 hub 票" "$RC $OUT $(cat "$TMP/log")"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/Arcrun#197); RC=$?
|
||||
[ "$RC" = 0 ] && grep -q '^PATCH /api/v1/repos/inkstone/Arcrun/issues/197' "$TMP/log" \
|
||||
&& ok "㉒ 同 repo 的票 ⇒ 掛進 hub 里程碑(原生欄位),不加邊" || no "㉒ 同 repo 沒走 PATCH" "$RC $OUT $(cat "$TMP/log")"
|
||||
grep -q '^POST' "$TMP/log" && no "㉓ 同 repo 不該 POST 相依" "$(grep POST "$TMP/log")" || ok "㉓ 同 repo 沒有 POST 相依"
|
||||
# 沒有 hub 票的狀態:直接把 state 裡的 hub_ticket 拿掉
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$HUB/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
for p in sys.argv[1:]:
|
||||
d = json.load(open(p)); d.pop("hub_ticket", None); json.dump(d, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
world_set 'w["deps"]["inkstone/Arcrun#86"].remove("inkstone/ISEP#130")'
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/ISEP#130); RC=$?
|
||||
[ "$RC" = 2 ] && ok "㉔ 別 repo 的票、沒 hub 票、沒 --via ⇒ 擋(離開碼 2)" || no "㉔ 沒擋" "$RC $OUT"
|
||||
case "$OUT" in *"--hub"*"--via"*) ok "㉕ 訊息給兩條路:set --hub/adopt --via" ;; *) no "㉕ 沒給路" "$OUT" ;; esac
|
||||
case "$OUT" in *"不是**出路"*|*"不是出路"*) ok "㉖ 訊息明講「開同名里程碑不是出路」" ;; *) no "㉖ 沒講" "$OUT" ;; esac
|
||||
case "$OUT" in *"inkstone/Arcrun#86"*) ok "㉗ 訊息列出 hub 里程碑裡有哪些票可以 --via(不用人自己去翻)" ;; *) no "㉗ 沒列候選" "$OUT" ;; esac
|
||||
[ "$(created_ms)" = 0 ] && ok "㉘ 擋下時也沒建里程碑" || no "㉘ 建了" "$(created_ms)"
|
||||
# 出路真的跑一次:把訊息裡 `adopt … --via <owner/repo#N>` 那行抽出來、填一格、執行
|
||||
LINE=$(printf '%s\n' "$OUT" | grep -E '^\s+\S+ adopt inkstone/ISEP#130 --via ' | head -1 | sed 's/<owner\/repo#N>/inkstone\/Arcrun#86/')
|
||||
OUT2=$(eval "$LINE" 2>&1); RC2=$?
|
||||
[ "$RC2" = 0 ] && ok "㉙ 訊息裡那條出路原樣貼、只填一格 ⇒ 真的跑得通(離開碼 0)" || no "㉙ 出路跑不動" "LINE=$LINE → $RC2 $OUT2"
|
||||
|
||||
echo "── C. 三個同名里程碑關掉之後(票上驗法第 1 條)──"
|
||||
world_set '
|
||||
for k in ("inkstone/mira#49","inkstone/ISEP#60","inkstone/arcrun-rag#61"): w["milestones"][k]["state"]="closed"
|
||||
for r in ("inkstone/mira#6","inkstone/ISEP#130","inkstone/arcrun-rag#104","inkstone/arcrun-rag#37"): w["issues"][r]["milestone"]=None
|
||||
'
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["members"] = []; d["hub_ticket"] = "inkstone/InkStoneCo#44"; json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
rm -f "$HUB/system-dev/mainline.json"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml refresh); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "" ] && ! grep -q dependencies "$TMP/log" \
|
||||
&& ok "㉚ 光 refresh(SessionStart 那條)不走相依(成本不變:真 Gitea 25 張要 19 秒)" || no "㉚ refresh 走了相依或炸了" "$RC $OUT $(cat "$TMP/log")"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㉚′ refresh --members 離開碼 0" || no "㉚′ refresh --members 失敗" "$OUT"
|
||||
M=$(members)
|
||||
for r in inkstone/mira#6 inkstone/ISEP#130 inkstone/ISEP#140 inkstone/arcrun-rag#104 inkstone/arcrun-rag#37; do
|
||||
case " $M " in *" $r "*) ok "㉛ 同名里程碑關掉、票也不掛任何里程碑之後,$r 仍是成員(靠邊,不靠名字)" ;; *) no "㉛ $r 掉出主線了" "$M" ;; esac
|
||||
done
|
||||
OUT=$(ml)
|
||||
case "$OUT" in *"mira 1"*"ISEP 2"*|*"ISEP 2"*"mira 1"*) ok "㉜ mainline show 列得出跨 repo 成員" ;; *) no "㉜ show 沒列出" "$OUT" ;; esac
|
||||
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" --all --json 2>&1); RC=$?
|
||||
REFS=$(printf '%s' "$OUT" | python3 -c "import json,sys; print(' '.join(r['ref'] for r in json.load(sys.stdin) if r['group']=='主線'))" 2>/dev/null)
|
||||
case " $REFS " in *" inkstone/ISEP#140 "*) ok "㉝ ticket pick 主線組抓得到 ISEP#140(沒里程碑、靠相依在線上、s/todo 沒人)" ;; *) no "㉝ pick 抓不到 ISEP#140" "$OUT" ;; esac
|
||||
case " $REFS " in *" inkstone/arcrun-rag#37 "*) ok "㉞ ticket pick 也抓得到 arcrun-rag#37(同名里程碑關掉後靠 hub 票的邊)" ;; *) no "㉞ pick 抓不到 arcrun-rag#37" "$OUT" ;; esac
|
||||
case " $REFS " in *"ISEP#130"*|*"mira#6"*|*"arcrun-rag#104"*) no "㉟ Human/s/doing/有人的票被抓走了" "$REFS" ;; *) ok "㉟ ISEP#130(Human)、mira#6(s/doing)、arcrun-rag#104(有人)照舊不被抓" ;; esac
|
||||
OUT=$(ml has inkstone/ISEP#140); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㊰ has ISEP#140 ⇒ 在(快取)" || no "has ISEP#140 說不在" "$OUT"
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["members"] = ["inkstone/Arcrun#86", "inkstone/Arcrun#176"]; json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
OUT=$(ml has inkstone/mira#6); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㊱ 快取裡沒有 mira#6 ⇒ has 去問「誰把它當相依」(/blocks)⇒ 在" || no "㊱ has 靠 blocks 判不出來" "$RC $OUT"
|
||||
OUT=$(ml has inkstone/Arcrun#50); RC=$?
|
||||
[ "$RC" = 1 ] && ok "㊲ has Arcrun#50(別條線、沒人指著它)⇒ 不在(離開碼 1)" || no "㊲ has Arcrun#50 判錯" "$RC $OUT"
|
||||
case "$OUT" in *"adopt inkstone/Arcrun#50"*) ok "㊳ 不在時給的出路是 adopt(不是去開同名里程碑)" ;; *) no "㊳ 出路不對" "$OUT" ;; esac
|
||||
|
||||
echo "── C′. focus guard 的新鮮查詢:問 milestone + /blocks,不問標題 ──"
|
||||
: > "$TMP/log"
|
||||
R=$(dispatch '【工單】inkstone/mira#6')
|
||||
[ "${R%%|*}" = 0 ] && ok "㊴ 派快取裡沒有、但被 Arcrun#86 當相依的 mira#6 ⇒ 放行(靠 /blocks 學到)" || no "㊴ 誤攔 mira#6" "$R"
|
||||
grep -q 'issues/6/blocks' "$TMP/log" && ok "㊵ 閘真的問了 /blocks" || no "㊵ 沒問 /blocks" "$(cat "$TMP/log")"
|
||||
grep -q 'inkstone/mira#6' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "㊶ 學到的成員寫回快取(下次不必再問)" || no "㊶ 沒寫回" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
R=$(dispatch '【工單】inkstone/Arcrun#50')
|
||||
[ "${R%%|*}" = 2 ] && ok "㊷ 派別條線的 Arcrun#50 ⇒ 擋一次" || no "㊷ 沒擋" "$R"
|
||||
case "${R#*|}" in *"不要**去那個 repo 開同名里程碑"*|*"不要"*"同名里程碑"*) ok "㊸ 擋下時的補收出路明講「不要去開同名里程碑」" ;; *) no "㊸ 出路沒講" "${R#*|}" ;; esac
|
||||
world_set 'w["milestones"]["inkstone/mira#49"]["state"]="open"; w["issues"]["inkstone/mira#5"]={"milestone":"inkstone/mira#49","labels":["s/todo"]}'
|
||||
R=$(dispatch '【工單】inkstone/mira#5')
|
||||
[ "${R%%|*}" = 2 ] && ok "㊹ 派一張掛在 mira **同名**里程碑、沒有任何邊的票 ⇒ 擋一次(同名不是主線)" || no "㊹ 同名被當成主線放行了" "$R"
|
||||
R=$(dispatch '【工單】inkstone/mira#5')
|
||||
[ "${R%%|*}" = 0 ] && ok "㊺ 重送 ⇒ 放行(至多擋一次)" || no "㊺ 鬼打牆" "$R"
|
||||
|
||||
echo "── D. refresh 讀不到 Gitea ⇒ 成員保留 ──"
|
||||
BEFORE=$(members); touch "$TMP/down"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
||||
rm -f "$TMP/down"
|
||||
|
||||
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
||||
OUT=$(cd "$HUB" && env -u CLAUDE_PROJECT_DIR python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊽ cwd 就是 InkStoneCo ⇒ 同一個地方" || no "㊽ cwd 形狀不對" "$OUT"
|
||||
# plugin 快取的形狀:原始碼被複製到一個沒有 .git 的目錄(claude plugin update 會整個換掉)
|
||||
CACHE="$TMP/plugins/cache/inkstone/isep/9.9.9"; mkdir -p "$CACHE/scripts" "$CACHE/hooks/lib" "$CACHE/system-dev"
|
||||
cp "$ACCOUNT" "$CACHE/scripts/"; cp "$ROOT/lib/mainline.py" "$CACHE/hooks/lib/"
|
||||
EMPTY="$TMP/shell-empty"; mkdir -p "$EMPTY"
|
||||
OUT=$(cd "$EMPTY" && CLAUDE_PROJECT_DIR="$EMPTY" python3 "$CACHE/scripts/milestone-account" ledger-path 2>&1)
|
||||
case "$OUT" in "$CACHE"*) no "㊾ 帳本落進 plugin 快取(下一版就不見)" "$OUT" ;; "$HOME/.claude/isep-estimates/ledger.jsonl") ok "㊾ 專案裡沒有 system-dev、原始碼又在沒 .git 的快取 ⇒ 帳本退到家目錄,**不落快取**" ;; *) no "㊾ 路徑不明" "$OUT" ;; esac
|
||||
mkdir -p "$CACHE/.git"
|
||||
OUT=$(cd "$EMPTY" && CLAUDE_PROJECT_DIR="$EMPTY" python3 "$CACHE/scripts/milestone-account" ledger-path 2>&1)
|
||||
[ "$OUT" = "$CACHE/system-dev/estimates/ledger.jsonl" ] && ok "㊿ 同一棵樹有 .git(真的 checkout,dogfood 的形狀)⇒ 才准用它的 system-dev/" || no "㊿ checkout 形狀不對" "$OUT"
|
||||
|
||||
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user