主線成員改由相依邊決定,不靠同名里程碑;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
+149 -78
View File
@@ -7,15 +7,16 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」
⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象——
要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。
這支不解「milestone 太多」(跨 repo 同名是既有做法,不動它),
它解的是「**這一刻我在做哪一條**」沒有答案:
mainline 現在的主線是哪一個(= show,唯一答案,離線)
mainline list 有哪些 open milestone 可以選(打網路)
mainline set <owner/repo#id> 把某一個 milestone 標成主線(打網路,寫進 state)
mainline set <owner/repo#id> [--hub <owner/repo#N>]
把某一個 milestone 標成主線(打網路,寫進 state);
--hub 指定這條線的載體票(別 repo 的票用相依指到它)
mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始)
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢)
mainline adopt <owner/repo#N> 「補收」:把一張票掛進主線(打網路)
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢)--members 順便重收成員
mainline adopt <owner/repo#N> [--via <hub 票>]
「補收」:把一張票掛進主線(打網路)。同 repo ⇒ 掛進 hub 里程碑;
別 repo ⇒ 成為 hub 票的相依。**不建同名里程碑。**
mainline has <owner/repo#N> 這張票在不在主線上(exit 0 =在,1 =不在)
🔴 **`<owner/repo#id>` 的 id 是 milestone 的 id,不是 issue 的號碼。**
@@ -30,17 +31,24 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個名字,錨在一個具體的 milestone 上 ────────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo。所以
**錨**(期限/進度/目標宣告都讀它)是 `owner/repo#id`
**成員判定**看的是「票所屬 milestone 的標題與主線標題相同」(不分 repo)。
兩件事都成立:同名跨 repo 照舊能用,而「現在在做哪一條」仍然只有一個答案
── 一條線只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133──────
🔴 這支的第一版檔頭寫「跨 repo 同名是既有做法,不動它」,成員也是照標題跨 repo
那句話被總管 09-07 當成規則抄進 InkStoneCo 的 ops-facts,還在 arcrun-rag 多建了一個同名的。
leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
不同 repo 用指針」(規則本身在 inkstone/ISEP#30、inkstone/InkStoneCo#44
現在的形狀(判準全部是 Gitea 的欄位,沒有標題比對):
· **錨** `owner/repo#<milestone id>`:整個 Gitea 只有這一個 milestone 物件,
期限/進度/目標宣告都讀它。
· **hub 票**`set --hub`)= 這條線的載體票。別 repo 的票要進主線 ⇒ 成為它的 dependency。
· **成員** = hub 里程碑裡的票 hub 票,沿 `/dependencies` 邊(跨 repo)收下去
`hooks/lib/mainline.py::collect_members`)。同名里程碑關掉之後成員一張都不會掉——
因為成員從來不是靠名字算的。
· `adopt` 別 repo 的票 ⇒ 在 hub 票上加一條 dependency**不會在那個 repo 建同名里程碑**。
"""
import importlib.machinery
import importlib.util
import os
import sys
import urllib.parse
from datetime import datetime, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -95,35 +103,35 @@ def org_repos(owner):
return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")]
def issues_in_milestone(owner, repo, title):
q = urllib.parse.urlencode({"state": "all", "type": "issues",
"milestones": title, "limit": 100})
rows = T.api_soft("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []
out = []
for it in rows:
if not isinstance(it, dict) or it.get("pull_request"):
continue
ms = it.get("milestone") or {}
# 🔴 再比對一次標題:`milestones=` 這個參數在不同 Gitea 版本上對「名稱 vs id」
# 的解讀不完全一致,拿回來的東西要自己確認過才算數(不要相信查詢字串)。
if ML.norm(ms.get("title")) != ML.norm(title):
continue
out.append("%s/%s#%d" % (owner, repo, it["number"]))
return out
def collect_members(ms):
"""成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo)。判準在 `hooks/lib/mainline.py`。"""
return ML.collect_members(T.api_soft, ms)
def collect_members(owner, title, anchor_repo):
"""跨 repo 收齊「掛在同名 milestone 底下」的票。"""
refs, scanned = [], []
repos = org_repos(owner)
if anchor_repo not in repos:
repos.append(anchor_repo)
for r in repos:
got = issues_in_milestone(owner, r, title)
if got:
scanned.append((r, len(got)))
refs.extend(got)
return sorted(set(refs)), scanned
def members_line(refs, info):
byrepo = {}
for r in refs:
byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
dist = "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items()))
return (" 成員 %d 張(里程碑裡 %d 相依收到 %d%s" % (
len(refs), len(info.get("milestone") or []), len(info.get("via_deps") or []),
("" + dist) if dist else ""))
def fetch_issue(ref):
parts = ML.split_ref(ref)
if not parts:
die("🔴 票的寫法是 owner/repo#N,你給的是:%s" % ref)
it = T.api_soft("/repos/%s/%s/issues/%d" % parts)
return parts, (it if isinstance(it, dict) and it.get("number") else None)
def fetch_blocks(parts):
"""誰把這張票當相依(`/blocks`)。讀不到 ⇒ None(讀不到 ≠ 沒有人)。"""
rows = T.api_soft("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)
if not isinstance(rows, list):
return None
return [r for r in (ML.issue_ref(x) for x in rows) if r]
def fetch_milestone(owner, repo, mid):
@@ -159,12 +167,16 @@ def cmd_show(argv):
print(" 目標:" + (first[:150] + "…" if len(first) > 150 else first))
print(" 期限:%s" % ML.due_phrase(ms))
print(" 進度:%d%d 張已關(%d%%" % (c, total, pct))
if ML.hub_ticket(ms):
print(" hub 票:%s(別 repo 的票用相依指到它)" % ML.hub_ticket(ms))
mem = sorted(ML.members(ms))
byrepo = {}
for r in mem:
byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
print(" 屬於主線的票:%d 張%s" % (
len(mem), ("" + "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) + "") if byrepo else ""))
if mem:
print(" " + "、".join(mem))
if ms.get("set_at"):
print(" 標定於:%s" % ms["set_at"])
if ms.get("refreshed_at"):
@@ -199,16 +211,36 @@ def cmd_list(argv):
# ── set ──────────────────────────────────────────────────────────────────
def opt_of(argv, name):
if name in argv and argv.index(name) + 1 < len(argv):
return argv[argv.index(name) + 1]
return None
def positional(argv, *flags_with_value):
return [a for i, a in enumerate(argv)
if not a.startswith("--") and (i == 0 or argv[i - 1] not in flags_with_value)]
def cmd_set(argv):
need_net()
if not argv:
die("用法:mainline set <owner/repo#milestone_id>")
owner, repo, mid = parse_ms_ref(argv[0])
pos = positional(argv, "--hub")
if not pos:
die("用法:mainline set <owner/repo#milestone_id> [--hub <owner/repo#N>]")
owner, repo, mid = parse_ms_ref(pos[0])
m = fetch_milestone(owner, repo, mid)
title = m.get("title")
refs, scanned = collect_members(owner, title, repo)
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
ok = ML.save({
hub = opt_of(argv, "--hub")
if hub:
parts, it = fetch_issue(hub)
if not it:
die("🔴 讀不到 hub 票 %s(票號寫法 owner/repo#N;讀不到就先別標,不要標一張猜的)" % hub)
hub = "%s/%s#%d" % parts
else:
prev = ML.load() or {}
# 重標同一條線而沒帶 --hub ⇒ 沿用原本的 hub 票;換一條線 ⇒ 不沿用(那是別條線的載體)
hub = ML.hub_ticket(prev) if prev.get("ref") == "%s/%s#%d" % (owner, repo, mid) else None
ms = {
"ref": "%s/%s#%d" % (owner, repo, mid),
"owner": owner, "repo": repo, "id": mid,
"title": title,
@@ -216,15 +248,22 @@ def cmd_set(argv):
"due_on": m.get("due_on") or "",
"open_issues": m.get("open_issues") or 0,
"closed_issues": m.get("closed_issues") or 0,
"members": refs,
"set_at": now, "refreshed_at": now,
}, mirror=True)
if not ok:
"hub_ticket": hub,
"members": [],
}
refs, info = collect_members(ms)
ms["members"] = refs
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
ms["set_at"] = ms["refreshed_at"] = now
if not ML.save(ms, mirror=True):
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
if scanned:
print(" 跨 repo 收到同名 milestone 的票 %d 張:%s"
% (len(refs), "、".join("%s %d" % (r, n) for r, n in scanned)))
if hub:
print(" hub 票:%s" % hub)
print(members_line(refs, info) if info.get("reached")
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
if info.get("capped"):
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP)
print(mirror_line())
print()
cmd_show([])
@@ -299,38 +338,66 @@ def cmd_refresh(argv):
"refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"),
})
if "--members" in argv:
refs, _ = collect_members(ms["owner"], ms["title"], ms["repo"])
if refs:
# 成員靠相依邊算(inkstone/ISEP#133):hub 里程碑一頁 每張成員一次 /dependencies。
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
# `set``adopt` 會重收,`pick``has`focus guard 本來就每次現問 Gitea,快取只餵 `show`。
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
try:
refs, info = collect_members(ms)
except SystemExit:
refs, info = [], {"reached": False}
if info.get("reached"):
ms["members"] = refs
ML.save(ms)
# ── adopt(補收)──────────────────────────────────────────────────────────
def cmd_adopt(argv):
"""補收。同 repo ⇒ 掛進 hub 里程碑(原生欄位);別 repo ⇒ 成為 hub 票的相依。
🔴 不會在別的 repo 建同名里程碑——那是被 leo 推翻的做法(本檔檔頭)。"""
need_net()
if not argv:
die("用法:mainline adopt <owner/repo#N>(把一張票掛進主線=「補收」)")
pos = positional(argv, "--via")
if not pos:
die("用法:mainline adopt <owner/repo#N> [--via <hub 票 owner/repo#N>](把一張票掛進主線=「補收」)")
ms = ML.load()
if not ms:
die("🔴 現在沒有主線,沒有東西可以補收進去。先 `scripts/mainline set …`。")
owner, repo, num = T.parse_ref(argv[0])
cands = [m for m in (T.api_soft("/repos/%s/%s/milestones?state=all" % (owner, repo)) or [])
if isinstance(m, dict) and ML.norm(m.get("title")) == ML.norm(ms["title"])]
if not cands:
die("""🔴 `%s/%s` 底下沒有叫「%s」的 milestone,所以這張票掛不進去。
兩條路,**都要人決定,這支不會替你選**:
· 這件事真的屬於主線 ⇒ 在 `%s/%s` 開一個同名 milestone(記得設真的期限,
`milestone-due-guard.sh` 會擋沒期限的),再跑一次 adopt
· 這件事其實是另一條線 ⇒ 那就是**跳線**,不是補收。直接重送派工
(本閘同一張票只擋一次),並在回覆說清楚為什麼現在要岔開主線。""" % (
owner, repo, ms["title"], owner, repo))
mid = cands[0]["id"]
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": mid}, method="PATCH")
owner, repo, num = T.parse_ref(pos[0])
ref = "%s/%s#%d" % (owner, repo, num)
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
if owner == ms["owner"] and repo == ms["repo"]:
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": ms["id"]}, method="PATCH")
how = "掛進 hub 里程碑 %s" % ML.ref_of(ms)
else:
via = opt_of(argv, "--via") or ML.hub_ticket(ms)
if not via:
_, info = collect_members(ms)
cands = info.get("milestone") or []
die("""🔴 %s 在別的 repo,要進主線得成為 hub 票的相依——而這條線還沒有 hub 票。
兩條路(擇一,都不會建同名里程碑):
· 指定這條線的載體票,之後每次 adopt 都用它:
%s set %s --hub <owner/repo#N>
· 或這一次點名要掛在哪一張底下:
%s adopt %s --via <owner/repo#N>
hub 里程碑裡現在有:%s
(在別的 repo 開一個同名里程碑**不是**出路——leo 2026-09-07:「早就定了只有一個 milestone,不同 repo 用指針」)""" % (
ref, os.path.abspath(sys.argv[0]), ML.ref_of(ms), os.path.abspath(sys.argv[0]), ref,
"、".join(cands) if cands else "(讀不到/沒有)"))
vparts = ML.split_ref(via)
if not vparts:
die("🔴 --via 的寫法是 owner/repo#N,你給的是:%s" % via)
via = "%s/%s#%d" % vparts
T.add_dependency(vparts, (owner, repo, num))
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
refs, info = collect_members(ms)
if info.get("reached"):
ms["members"] = sorted(set(refs) | {ref})
else:
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
ML.save(ms, mirror=True)
print("✅ 補收:%s 已掛進「%s」(%s/%s#%d" % (ref, ms["title"], owner, repo, mid))
print("✅ 補收:%s → %s" % (ref, how))
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)")
print(mirror_line())
@@ -345,18 +412,22 @@ def cmd_has(argv):
return
v = ML.belongs(ref, ms)
if v is None:
owner, repo, num = T.parse_ref(ref)
it = T.api_soft("/repos/%s/%s/issues/%d" % (owner, repo, num))
title = ((it or {}).get("milestone") or {}).get("title") if isinstance(it, dict) else None
v = ML.belongs(ref, ms, ticket_milestone_title=title or "")
if not isinstance(it, dict):
need_net()
parts, it = fetch_issue(ref)
if not it:
print("⚪ 讀不到 %s,判不出來(讀不到 ≠ 不屬於)。" % ref)
sys.exit(0)
blocks = fetch_blocks(parts)
v = ML.belongs(ref, ms, ticket_milestone=it.get("milestone") or {}, blocks=blocks)
if v is None:
print("⚪ %s:讀得到票,但讀不到「誰把它當相依」,判不出來(讀不到 ≠ 不屬於)。" % ref)
sys.exit(0)
if v:
print("✅ %s 在主線「%s」上。" % (ref, ms["title"]))
sys.exit(0)
print("🚧 %s **不在**主線「%s」上。\n"
" 補收 ⇒ `scripts/mainline adopt %s`;真的是插件事 ⇒ 那是跳線,說清楚再做。"
" 補收 ⇒ `scripts/mainline adopt %s`(別 repo 的票會成為 hub 票的相依);"
"真的是插件事 ⇒ 那是跳線,說清楚再做。"
% (ref, ms["title"], ref))
sys.exit(1)