主線成員改由相依邊決定,不靠同名里程碑;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
+89 -35
View File
@@ -132,6 +132,10 @@ def token():
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
_TOLERATE = () # 這一次呼叫可以吞掉的 HTTP 狀態碼(見 add_dependency
_TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None 是「回應是 null」
def api(path, payload=None, method=None):
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
@@ -141,6 +145,8 @@ def api(path, payload=None, method=None):
try:
return json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
if e.code in _TOLERATE:
return _TOLERATED
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
@@ -663,6 +669,22 @@ def _deps(owner, repo, num):
return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
def add_dependency(parent, child):
"""讓 `child` 成為 `parent` 的相依(兩個都是 (owner, repo, num))。**冪等**:邊已經在
(Gitea 回 409)就當成功,回 False——`mainline adopt` 補收同一張票兩次不該炸。
`subtask` 與 `mainline adopt`inkstone/ISEP#133)都走這一支:掛相依只有一條路,
而且走的是 `api()`——離線測試把 `api()` 換成錄音機時才錄得到它。"""
global _TOLERATE
po, pr, pn = parent
co, cr, cn = child
_TOLERATE = (409,)
try:
r = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies", {"owner": co, "repo": cr, "index": cn})
finally:
_TOLERATE = ()
return r is not _TOLERATED
def _blocks(owner, repo, num):
"""反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。
@@ -845,7 +867,8 @@ def subtask_example(parent=None, draft=None):
USAGE = {
"pick":
"用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--claim [--as <登入名>] [--name <身份>]]\n"
" 回**一張**現在可以抓的票(主線里程碑抓盡才輪到逾期里程碑;不抓 backlog、不抓沒里程碑的)\n"
" 回**一張**現在可以抓的票(主線抓盡才輪到逾期里程碑;不抓 backlog)。主線的成員=hub 里程碑裡的票+\n"
" hub 票+它們的相依(跨 repo)——別 repo 的同名里程碑不算主線(inkstone/ISEP#133\n"
" --mainline 不給時依序找:$ISEP_MAINLINE_FILE → ~/.claude/isep-countdown/mainline.json"
" → $CLAUDE_PROJECT_DIR/system-dev/mainline.json\n"
" 離開碼:0 = 有票(印在 stdout);1 = 真的沒有可抓的票;2 = 讀不到(讀不到 ≠ 沒有)",
@@ -966,8 +989,7 @@ def cmd_subtask(argv):
print(f" 下一步:{nxt}")
# 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到
api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies",
{"owner": ORG, "repo": repo, "index": cnum})
add_dependency((powner, prepo, pnum), (ORG, repo, cnum))
deps = _deps(powner, prepo, pnum)
openn = [i for i in deps if i["state"] == "open"]
print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)")
@@ -1299,6 +1321,10 @@ def pickable(issue, milestone_title):
# 2026-09-07 實查:ISEP#3135 五張 hub 掛在逾期里程碑上、s/todo、沒人——
# 照欄位規則會被抓走,但它們不是一件可以做完的事。容器不是任務。
return False, "是 hubscope 容器,不是一件可以做完的事)"
if milestone_title is None:
# inkstone/ISEP#133:呼叫端已經用相依邊確認它在主線上(別 repo 的票靠相依進來,
# 本來就不掛 hub 里程碑)⇒ 這一格不看里程碑。
return True, "ok"
ms = (issue.get("milestone") or {}).get("title")
if not ms:
return False, "沒有里程碑(backlog/沒排進 sprint 的不抓)"
@@ -1389,40 +1415,65 @@ def _issues_in(owner, repo, title):
return [it for it in rows if isinstance(it, dict)]
def _fetch_issue(ref):
ML = _mainline_lib()
parts = ML.split_ref(ref)
if not parts:
return None
it = api("/repos/%s/%s/issues/%d" % parts)
if not isinstance(it, dict) or not it.get("number"):
return None
it.setdefault("repository", {"full_name": "%s/%s" % parts[:2]})
return it
def mainline_members(ms):
"""主線的成員(inkstone/ISEP#133):hub 里程碑裡的票+hub 票+它們的相依(跨 repo)。
判準在 `hooks/lib/mainline.py::collect_members`,這裡只是把本檔的 `api` 遞進去。"""
return _mainline_lib().collect_members(api, ms)
def pick_candidates(ms, now=None):
"""依序回 [(組名, 里程碑標題, [可抓的票…])]:第一組是主線,之後是逾期的 open 里程碑
(最逾期的先)。**每一組裡都已經照 pick_order 排好。**"""
(最逾期的先)。**每一組裡都已經照 pick_order 排好。**
主線那一組的成員由相依邊決定(`mainline_members`),**不是**同名里程碑:
別 repo 裡叫同一個名字的里程碑,裡面的票不會被當成主線抓走(inkstone/ISEP#133)。
逾期那幾組是**一個里程碑物件一組**(owner/repo#id),同名不合併。"""
ML = _mainline_lib()
now = now or _now()
owner = ms.get("owner") or ORG
anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id"))
refs, info = mainline_members(ms)
found = []
for ref in refs:
it = _fetch_issue(ref)
if not it or (it.get("state") or "open") != "open":
continue
ok, _ = pickable(it, None)
if ok:
found.append(it)
groups = [("主線", ms["title"], sorted(found, key=pick_order))]
repos = _repos(owner)
opened = _open_milestones(owner, repos)
groups = []
def collect(title):
found = []
for r, m in opened:
if ML.norm(m.get("title")) != ML.norm(title):
continue
for it in _issues_in(owner, r, m.get("title")):
ok, _ = pickable(it, title)
if ok:
found.append(it)
return sorted(found, key=pick_order)
groups.append(("主線", ms["title"], collect(ms["title"])))
overdue = {}
for r, m in opened:
if ML.norm(m.get("title")) == ML.norm(ms["title"]):
overdue = []
for r, m in _open_milestones(owner, repos):
if "%s/%s#%s" % (owner, r, m.get("id")) == anchor:
continue
due = ML.due_of(m)
if due and due < now:
key = ML.norm(m.get("title"))
if key not in overdue or due < overdue[key][0]:
overdue[key] = (due, m.get("title"))
for due, title in sorted(overdue.values()):
groups.append(("逾期里程碑", title, collect(title)))
overdue.append((due, r, m))
for due, r, m in sorted(overdue, key=lambda x: x[0]):
found = []
for it in _issues_in(owner, r, m.get("title")):
if str((it.get("milestone") or {}).get("id")) != str(m.get("id")):
continue
ok, _ = pickable(it, m.get("title"))
if ok:
found.append(it)
groups.append(("逾期里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")),
sorted(found, key=pick_order)))
return groups
@@ -1496,17 +1547,20 @@ def cmd_claim(argv):
issue = api("/repos/%s/%s/issues/%d" % (owner, repo, num))
issue.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
ims = issue.get("milestone") or {}
ok, why = pickable(issue, ims.get("title") or "")
if ok:
on_mainline = ML.norm(ims.get("title")) == ML.norm(ms["title"])
ref = "%s/%s#%d" % (owner, repo, num)
# 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題
on_mainline = ref in set(mainline_members(ms)[0])
ok, why = pickable(issue, None if on_mainline else (ims.get("title") or ""))
if ok and not on_mainline:
due = ML.due_of(ims)
if not on_mainline and not (due and due < _now()):
ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期" % (ims.get("title"), ms["title"])
if not (due and due < _now()):
ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期(別 repo 的同名里程碑不算主線)" % (
ims.get("title"), ms["title"])
if not ok:
die("""🚫 %s/%s#%d 現在不能抓:%s
抓票規則(inkstone/ISEP#131,全部是 Gitea 欄位):
s/* 恰好是 s/todo 沒有 assignee 沒有 Humanhuman/* 里程碑是主線(主線抓盡才抓逾期)
s/* 恰好是 s/todo 沒有 assignee 沒有 Humanhuman/* 在主線上(hub 里程碑或相依;主線抓盡才抓逾期)
出路:`%s pick` 會回一張符合規則的票;這張如果真的該你做,回票上問總管改欄位,不要硬抓。""" % (
owner, repo, num, why, self_path()))
@@ -1515,7 +1569,7 @@ def cmd_claim(argv):
_set_labels(owner, repo, num, keep)
lines = [ident, "",
"🙋 **認領** → `%s`" % who, "",
"- 里程碑:「%s」%s" % (ims.get("title"), "(主線)" if ML.norm(ims.get("title")) == ML.norm(ms["title"]) else "(逾期里程碑,主線已抓盡)"),
"- 里程碑:「%s」%s" % (ims.get("title") or "(無,靠相依在主線上)", "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)"),
"- 狀態:s/todo → s/doing",
"- 做完:分支+證據 → `s/review` `ticket handback`(不推 main、不出貨、不改里程碑)"]
api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)})