主線成員改由相依邊決定,不靠同名里程碑;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:
+89
-35
@@ -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#31–35 五張 hub 掛在逾期里程碑上、s/todo、沒人——
|
||||
# 照欄位規則會被抓走,但它們不是一件可以做完的事。容器不是任務。
|
||||
return False, "是 hub(scope 容器,不是一件可以做完的事)"
|
||||
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 + 沒有 Human/human/* + 里程碑是主線(主線抓盡才抓逾期)
|
||||
s/* 恰好是 s/todo + 沒有 assignee + 沒有 Human/human/* + 在主線上(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)})
|
||||
|
||||
Reference in New Issue
Block a user