主線成員改由相依邊決定,不靠同名里程碑;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:
+149
-78
@@ -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)
|
||||
|
||||
|
||||
@@ -41,21 +41,48 @@ import urllib.request
|
||||
HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
def _project_roots():
|
||||
"""跟 `hooks/lib/mainline.py::project_roots()` 同一把尺(那裡是唯一定義;載不到才退回同形狀的內建)。"""
|
||||
try:
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
path = os.path.join(ROOT, "hooks", "lib", "mainline.py")
|
||||
loader = importlib.machinery.SourceFileLoader("isep_mainline_for_account", path)
|
||||
spec = importlib.util.spec_from_file_location("isep_mainline_for_account", path, loader=loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(mod)
|
||||
return mod.project_roots()
|
||||
except Exception:
|
||||
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 _resolve_dir():
|
||||
"""帳本住哪裡。
|
||||
|
||||
🔴 **不能住在 plugin 目錄裡**:ISEP 是用 marketplace 裝的,
|
||||
`claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零,
|
||||
而這張票的全部價值就在「累積」。
|
||||
順序:環境變數 → 這個 session 的專案根底下的 `system-dev/`(總管的跨專案知識庫,
|
||||
帳本本來就屬於那一層)→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。
|
||||
inkstone/ISEP#133 實撞:雲端的 `$CLAUDE_PROJECT_DIR` 是薄殼根(沒有 `system-dev/`),
|
||||
上一版於是退到「原始碼所在 repo 的 system-dev/」——而雲端的原始碼就在
|
||||
`~/.claude/plugins/cache/inkstone/isep/<版本>/`,那天記的 5 筆帳下一個 session 就不見。
|
||||
順序:環境變數 → 專案根/專案根底下的 `InkStoneCo/`/cwd 同兩層(跟主線檔同一把尺,
|
||||
帳本本來就屬於 InkStoneCo 那一層)→ 這份原始碼所在 repo 的 `system-dev/`,**但只在
|
||||
它是一個 git 工作樹時**(有 `.git`=真的 checkout;plugin 快取沒有 `.git`)→ `~/.claude/`。
|
||||
"""
|
||||
v = os.environ.get("MILESTONE_ACCOUNT_DIR")
|
||||
if v:
|
||||
return v
|
||||
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
|
||||
if base and os.path.isdir(os.path.join(base, "system-dev")):
|
||||
for base in _project_roots():
|
||||
if os.path.isdir(os.path.join(base, "system-dev")):
|
||||
return os.path.join(base, "system-dev", "estimates")
|
||||
if os.path.exists(os.path.join(ROOT, ".git")) and os.path.isdir(os.path.join(ROOT, "system-dev")):
|
||||
return os.path.join(ROOT, "system-dev", "estimates")
|
||||
return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates")
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr
|
||||
if pr: d["pull_request"] = {"merged": False}
|
||||
return d
|
||||
POOL = {
|
||||
"ISEP": [I("ISEP", 1, ["s/todo"], "main"), # ★ 可抓
|
||||
"ISEP": [I("ISEP", 1, ["s/todo"], None), # ★ 可抓:沒里程碑,靠相依邊在主線上
|
||||
I("ISEP", 13, ["s/todo"], "main"), # ISEP 裡「同名」的里程碑:不是主線(ISEP#133)
|
||||
I("ISEP", 2, ["s/todo", "Human"], "main"), # 票上第 1 條:Human
|
||||
I("ISEP", 3, ["s/todo"], "main", assignees=("someone",)), # 票上第 1 條:有 assignee
|
||||
I("ISEP", 4, ["s/backlog"], None), # 票上第 1 條:backlog
|
||||
@@ -59,6 +60,16 @@ POOL = {
|
||||
"mira": [I("mira", 9, ["s/todo", "s/doing"], "main")], # 09-07 實查 mira#6 的形狀:有人在做
|
||||
}
|
||||
MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]}
|
||||
# 相依邊(inkstone/ISEP#133):主線錨在 Arcrun#48,Arcrun#6 把 ISEP#1 當相依 ⇒ ISEP#1 在主線上;
|
||||
# ISEP#13 掛在 ISEP 的同名里程碑但沒有任何邊 ⇒ 不在主線上。mira#9 同理(同名、沒邊)。
|
||||
DEPS = {"Arcrun#6": ["ISEP#1"]}
|
||||
BLOCKS = {}
|
||||
for _p, _cs in DEPS.items():
|
||||
for _c in _cs:
|
||||
BLOCKS.setdefault(_c, []).append(_p)
|
||||
def _by_ref(ref):
|
||||
repo, n = ref.split("#")
|
||||
return next(i for i in POOL[repo] if i["number"] == int(n))
|
||||
DROP = set(json.loads(os.environ.get("DROP", "[]"))) # 測「主線抓盡」用:把某幾張從池子拿掉
|
||||
def fake_api(path, payload=None, method=None):
|
||||
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
|
||||
@@ -80,6 +91,10 @@ def fake_api(path, payload=None, method=None):
|
||||
return [{"name": n, "id": k} for k, n in
|
||||
enumerate(["s/todo", "s/doing", "s/review", "p/high", "Human"], 1)]
|
||||
import re
|
||||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)/(dependencies|blocks)(\?.*)?$", path)
|
||||
if m:
|
||||
table = DEPS if m.group(3) == "dependencies" else BLOCKS
|
||||
return [_by_ref(r) for r in table.get(f"{m.group(1)}#{m.group(2)}", [])]
|
||||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)$", path)
|
||||
if m and (method or "GET") == "GET":
|
||||
return next(i for i in POOL[m.group(1)] if i["number"] == int(m.group(2)))
|
||||
@@ -142,6 +157,12 @@ p "★ 給定主線檔 → 回一張,而且是主線裡最優先的 Arcrun#6
|
||||
p "只回一張(不是整份清單)" "1" "out.count(' ● ')" "$ARGS"
|
||||
p "★ --all:主線裡可抓的恰好是 Arcrun#6、ISEP#1 兩張——Human/assignee/backlog 三張都不在" \
|
||||
"['inkstone/Arcrun#6', 'inkstone/ISEP#1']" "[r['ref'] for r in json.loads(out) if r['group']=='主線']" "$ALL"
|
||||
p "★ ISEP#1 沒掛里程碑,是靠「Arcrun#6 把它當相依」進主線的(成員=相依邊,ISEP#133)" "True" \
|
||||
"'inkstone/ISEP#1' in out and any(p.endswith('/issues/6/dependencies?limit=100') for _,p,_ in rec)" "$ALL"
|
||||
p "★ ISEP#13 掛在 ISEP 裡**同名**的里程碑、沒有任何邊 → 不在主線裡(同名不算,leo 09-07)" "False" \
|
||||
"'inkstone/ISEP#13' in out" "$ALL"
|
||||
p "主線那一組只讀 hub 里程碑那一個 repo 的 milestone 清單(不去別的 repo 找同名)" "True" \
|
||||
"not any('/repos/inkstone/ISEP/issues?' in p and '主線' in p for _,p,_ in rec)" "$ALL"
|
||||
p "★ --all:逾期里程碑那張 Arcrun#7 排在主線之後(主線抓盡才輪到)" \
|
||||
"['inkstone/Arcrun#7']" "[r['ref'] for r in json.loads(out) if r['group']!='主線']" "$ALL"
|
||||
p "沒逾期也不是主線的 Arcrun#8 不在清單裡" "False" "'inkstone/Arcrun#8' in out" "$ALL"
|
||||
@@ -201,6 +222,10 @@ blocked "已指派的 ISEP#3" inkstone/ISEP#3
|
||||
blocked "backlog 的 ISEP#4" inkstone/ISEP#4
|
||||
blocked "沒逾期也不是主線的 Arcrun#8" inkstone/Arcrun#8
|
||||
blocked "有人在做的 mira#9(s/doing 也掛著)" inkstone/mira#9
|
||||
blocked "掛在 ISEP 同名里程碑、沒有相依邊的 ISEP#13(同名不是主線)" inkstone/ISEP#13
|
||||
p "claim 靠相依進主線的 ISEP#1(沒里程碑)→ 過(留言寫「靠相依在主線上」)" "True" \
|
||||
"rc is None and any('靠相依' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" \
|
||||
"[\"cmd_claim\",\"inkstone/ISEP#1\",\"--mainline\",\"$MAIN\"]"
|
||||
p "擋下時訊息講得出理由與出路(pick)" "True" "'Human' in out and 'pick' in out" \
|
||||
"[\"cmd_claim\",\"inkstone/ISEP#2\",\"--mainline\",\"$MAIN\"]"
|
||||
p "身份名不在名單上 → 在打任何 API 之前就擋(一通都沒打)" "True" "rc==2 and len(rec)==0" \
|
||||
|
||||
+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