d4bf172990
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
456 lines
23 KiB
Python
Executable File
456 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""mainline — 「現在的主線是哪一個」這個問題的**唯一指令**(inkstone/ISEP#82)。
|
||
|
||
leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」。
|
||
當天實查 `GET /repos/inkstone/*/milestones?state=open` 回 14 個,其中「Mira 現代化」
|
||
這個名字同時活在 5 個 repo,另有 5 個已經逾期。
|
||
⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象——
|
||
要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。
|
||
|
||
mainline 現在的主線是哪一個(= show,唯一答案,離線)
|
||
mainline list 有哪些 open milestone 可以選(打網路)
|
||
mainline set <owner/repo#id> [--hub <owner/repo#N>]
|
||
把某一個 milestone 標成主線(打網路,寫進 state);
|
||
--hub 指定這條線的載體票(別 repo 的票用相依指到它)
|
||
mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始)
|
||
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 的號碼。**
|
||
兩者在 Gitea 是兩套獨立編號,`mainline list` 印出來的就是可以直接貼的那一個。
|
||
|
||
── 答案為什麼唯一 ─────────────────────────────────────────────────
|
||
主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。
|
||
(inkstone/ISEP#130 起它有一份隨 repo 走的鏡像:`InkStoneCo/system-dev/mainline.json`——
|
||
雲端沒有家目錄那份,讀的就是它;`set`/`adopt`/`clear` 兩份一起寫,`refresh` 只寫家目錄。
|
||
inkstone/ISEP#140 起:兩份都不在時(雲端薄殼 bootstrap 之前的形狀),`refresh` 會去 Gitea
|
||
讀 `inkstone/InkStoneCo` main 上那份一次、寫進家目錄——優先序寫在 `hooks/lib/mainline.py` 檔頭。)
|
||
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
|
||
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
|
||
|
||
── 一條線只有一個里程碑;別的 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
|
||
from datetime import datetime, timezone
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
|
||
def _load(name, path):
|
||
"""把一個檔載進來當模組。`scripts/ticket` 沒有 `.py` 副檔名,所以要明著給 loader
|
||
(`spec_from_file_location` 只靠副檔名認不出它,會回 None)。"""
|
||
loader = importlib.machinery.SourceFileLoader(name, path)
|
||
spec = importlib.util.spec_from_file_location(name, path, loader=loader)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
# 憑證怎麼拿、API 怎麼打、票號怎麼解析——**全部沿用 `scripts/ticket` 那一份**。
|
||
# 🔴 刻意不自己再寫一份 token():金鑰的取得只准有一條路(頂層 CLAUDE.md 金鑰鐵律
|
||
# + 2026-07-29 t145 的實害——同一把金鑰兩種寫法並存,必然漂移)。
|
||
ML = _load("isep_mainline", os.path.join(HERE, "..", "hooks", "lib", "mainline.py"))
|
||
try:
|
||
T = _load("isep_ticket", os.path.join(HERE, "ticket"))
|
||
except Exception:
|
||
# 🔴 載不到就只是「打不了網路的那幾個動作不能用」,不是整支壞掉。
|
||
# ISEP#82 驗收第 4 條要的是「沒有主線也不能整組壞掉」,同一個道理:
|
||
# `show` 是離線的,它不該因為別的零件壞了而回答不出來。
|
||
T = None
|
||
|
||
|
||
def die(msg, code=2):
|
||
print(msg, file=sys.stderr)
|
||
sys.exit(code)
|
||
|
||
|
||
def need_net():
|
||
if T is None:
|
||
die("🔴 載不到 `scripts/ticket`(憑證與 API 都沿用它),這個動作打不了網路。\n"
|
||
" `scripts/mainline show` 仍然可以用——它是離線的。")
|
||
|
||
|
||
def parse_ms_ref(s):
|
||
"""`owner/repo#<milestone id>`。與票號同形,所以錯手貼票號會拿到 404,訊息要講清楚。"""
|
||
import re
|
||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (s or "").strip())
|
||
if not m:
|
||
die("🔴 寫法是 owner/repo#<milestone id>,你給的是:%s\n"
|
||
"(先跑 `scripts/mainline list` 看有哪些,那裡印的就是可以直接貼的)" % s)
|
||
return m.group(1), m.group(2), int(m.group(3))
|
||
|
||
|
||
def org_repos(owner):
|
||
rows = T.api_soft("/orgs/%s/repos?limit=100" % owner) or []
|
||
return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")]
|
||
|
||
|
||
def collect_members(ms):
|
||
"""成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo)。判準在 `hooks/lib/mainline.py`。"""
|
||
return ML.collect_members(T.api_soft, ms)
|
||
|
||
|
||
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):
|
||
d = T.api_soft("/repos/%s/%s/milestones/%d" % (owner, repo, mid))
|
||
if not isinstance(d, dict) or not d.get("title"):
|
||
die("🔴 讀不到 %s/%s#%d 這個 milestone。\n"
|
||
" · id 是 milestone 的 id,不是 issue 的號碼(`scripts/mainline list` 會印對的)\n"
|
||
" · 或是這台機器現在拿不到 Gitea(那就先別標,不要標一個猜的)" % (owner, repo, mid))
|
||
return d
|
||
|
||
|
||
# ── show ─────────────────────────────────────────────────────────────────
|
||
def cmd_show(argv):
|
||
"""離線。**永遠回一個答案**,包括「現在沒有主線」(ISEP#82 驗收第 4 條)。"""
|
||
ms = ML.load()
|
||
if not ms:
|
||
print("🎯 現在沒有主線。")
|
||
print()
|
||
print(" 這不是故障——沒有人標過,或上一條收掉了。")
|
||
print(" 要標一條:`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`")
|
||
print()
|
||
print("📌 在標之前,每回合的 🎯 那一行也會照樣講「現在沒有主線」,不會編一條出來。")
|
||
print(" 雲端:SessionStart 的 `mainline refresh` 會先去 Gitea 讀 inkstone/InkStoneCo main 的")
|
||
print(" system-dev/mainline.json;還是沒有 ⇒ 那份不存在,或這台拿不到 Gitea(inkstone/ISEP#140)。")
|
||
return
|
||
c, total, pct = ML.progress(ms)
|
||
print("🎯 現在的主線:%s「%s」" % (ML.ref_of(ms), ms.get("title")))
|
||
desc = (ms.get("description") or "").strip()
|
||
if desc:
|
||
first = desc.splitlines()[0].strip()
|
||
# 只印第一行、最多 150 字:這是**一眼掃**的答案,不是 milestone 的全文。
|
||
# leo 一天看兩眼(白話鐵律),整段貼過來他就不會看。
|
||
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"):
|
||
print(" 最後更新:%s" % ms["refreshed_at"])
|
||
print()
|
||
print("📌 這是唯一答案——主線住在一個檔裡,那個檔放得下一條:%s" % ML.path())
|
||
|
||
|
||
# ── list ─────────────────────────────────────────────────────────────────
|
||
def cmd_list(argv):
|
||
need_net()
|
||
owner = argv[0] if argv else T.ORG
|
||
rows = []
|
||
for r in org_repos(owner):
|
||
for m in (T.api_soft("/repos/%s/%s/milestones?state=open" % (owner, r)) or []):
|
||
if not isinstance(m, dict):
|
||
continue
|
||
rows.append((owner, r, m))
|
||
if not rows:
|
||
die("🔴 一個 open milestone 都沒讀到——多半是拿不到 Gitea,不是真的沒有。\n"
|
||
"(讀不到 ≠ 不存在。先修連線,不要因此標一個猜的主線。)", 1)
|
||
now = datetime.now(timezone.utc)
|
||
cur = ML.load()
|
||
print("open milestone 共 %d 個(%s):\n" % (len(rows), owner))
|
||
for o, r, m in sorted(rows, key=lambda x: (ML.due_of(x[2]) or datetime.max.replace(tzinfo=timezone.utc))):
|
||
c, total, pct = ML.progress(m)
|
||
mark = " ← 現在的主線" if cur and ML.ref_of(cur) == "%s/%s#%s" % (o, r, m.get("id")) else ""
|
||
print(" %-28s %-24s %3d%% 期限 %s%s" % (
|
||
"%s/%s#%s" % (o, r, m.get("id")), (m.get("title") or "")[:24],
|
||
pct, ML.due_phrase(m, now), mark))
|
||
print("\n📌 貼左邊那一欄:`scripts/mainline set <owner/repo#id>`")
|
||
|
||
|
||
# ── 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()
|
||
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")
|
||
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,
|
||
"description": m.get("description") or "",
|
||
"due_on": m.get("due_on") or "",
|
||
"open_issues": m.get("open_issues") or 0,
|
||
"closed_issues": m.get("closed_issues") or 0,
|
||
"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 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([])
|
||
|
||
|
||
def mirror_line():
|
||
"""repo 那份有沒有跟著寫(inkstone/ISEP#130):寫了要人去 commit,沒寫要講為什麼。"""
|
||
rp = ML.repo_path(for_write=True)
|
||
if rp and os.path.isfile(rp):
|
||
return (" 已同步寫進 repo 那份:%s\n"
|
||
" 📌 這份要 commit/開 PR 併進 inkstone/InkStoneCo,雲端 clone 才拿得到" % rp)
|
||
return (" ⚠️ 這台找不到 InkStoneCo 的 system-dev/(CLAUDE_PROJECT_DIR 或 cwd 底下都沒有),"
|
||
"repo 那份沒更新——雲端仍會讀到舊主線")
|
||
|
||
|
||
def cmd_clear(argv):
|
||
had = ML.load()
|
||
rp = ML.repo_path()
|
||
ML.clear(mirror=True)
|
||
print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
|
||
% ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else ""))
|
||
if rp:
|
||
print(" repo 那份也刪了:%s(要 commit 才會到雲端)" % rp)
|
||
|
||
|
||
# ── refresh ──────────────────────────────────────────────────────────────
|
||
def soft_token():
|
||
"""拿得到就給,拿不到回空字串。`refresh` 跑在 SessionStart,**不准因為沒 token 就整支炸**
|
||
——雲端薄殼的 remote 是 GitHub、家目錄是空的,那正是它最需要活著的時候。"""
|
||
if T is None:
|
||
return ""
|
||
import contextlib
|
||
import io
|
||
try:
|
||
with contextlib.redirect_stderr(io.StringIO()): # token() 拿不到會先抱怨再 exit:兩個都吞掉
|
||
return T.token() or ""
|
||
except (SystemExit, Exception):
|
||
return ""
|
||
|
||
|
||
def cmd_refresh(argv):
|
||
"""SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。
|
||
|
||
inkstone/ISEP#140:家目錄與 repo 那份都不在(雲端薄殼跑 SessionStart 時 `InkStoneCo/`
|
||
還沒 clone)⇒ 去 Gitea 讀 `inkstone/InkStoneCo` main 上那份**一次**,寫進家目錄。
|
||
之後每一則 UserPromptSubmit 讀的是家目錄,路徑上照舊沒有網路。
|
||
讀不到 ⇒ 什麼都不寫,退回現行行為(`countdown.py` 的第二基準)。
|
||
"""
|
||
ms = ML.load()
|
||
if not ms:
|
||
got = ML.fetch_gitea(token=soft_token) # 傳函式:匿名讀得到就不去碰 token
|
||
if not got:
|
||
return # 兩份都沒有、Gitea 也問不到 ⇒ 真的沒有主線,安靜結束
|
||
ML.save(got) # 只寫家目錄:雲端沒有 InkStoneCo/ 可寫,本機不弄髒工作樹
|
||
ms = ML.load()
|
||
if not ms:
|
||
return
|
||
if T is None:
|
||
return
|
||
try:
|
||
d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
|
||
except SystemExit:
|
||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||
if not isinstance(d, dict) or not d.get("title"):
|
||
return # 讀不到 ≠ 主線不存在
|
||
ms.update({
|
||
"title": d.get("title") or ms["title"],
|
||
"description": d.get("description") or ms.get("description") or "",
|
||
"due_on": d.get("due_on") or ms.get("due_on") or "",
|
||
"open_issues": d.get("open_issues") or 0,
|
||
"closed_issues": d.get("closed_issues") or 0,
|
||
"refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"),
|
||
})
|
||
if "--members" in argv:
|
||
# 成員靠相依邊算(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()
|
||
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(pos[0])
|
||
ref = "%s/%s#%d" % (owner, repo, num)
|
||
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" % (ref, how))
|
||
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)")
|
||
print(mirror_line())
|
||
|
||
|
||
# ── has ──────────────────────────────────────────────────────────────────
|
||
def cmd_has(argv):
|
||
if not argv:
|
||
die("用法:mainline has <owner/repo#N>")
|
||
ms = ML.load()
|
||
ref = argv[0].strip()
|
||
if not ms:
|
||
print("🎯 現在沒有主線 ⇒ 沒有「不屬於主線」這回事。")
|
||
return
|
||
v = ML.belongs(ref, ms)
|
||
if v is None:
|
||
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`(別 repo 的票會成為 hub 票的相依);"
|
||
"真的是插件事 ⇒ 那是跳線,說清楚再做。"
|
||
% (ref, ms["title"], ref))
|
||
sys.exit(1)
|
||
|
||
|
||
CMDS = {"show": cmd_show, "list": cmd_list, "set": cmd_set, "clear": cmd_clear,
|
||
"refresh": cmd_refresh, "adopt": cmd_adopt, "has": cmd_has}
|
||
|
||
if __name__ == "__main__":
|
||
# `mainline | head` 是很自然的用法,別讓它吐一段 BrokenPipe 的 traceback
|
||
# ——那會讓人以為這支壞了。
|
||
try:
|
||
import signal
|
||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||
except Exception:
|
||
pass
|
||
if len(sys.argv) < 2:
|
||
cmd_show([])
|
||
sys.exit(0)
|
||
if sys.argv[1] in ("-h", "--help", "help"):
|
||
print(__doc__)
|
||
sys.exit(0)
|
||
if sys.argv[1] not in CMDS:
|
||
print(__doc__)
|
||
sys.exit(2)
|
||
CMDS[sys.argv[1]](sys.argv[2:])
|