併進 main(v0.12.0)並接上「主線唯一答案」(inkstone/ISEP#83)

衝突只有 docs/TESTING.md 一處,兩邊都留:
  main 的 A16(gate-ok)/A17(未推警察)/A18(信標)原樣保留,
  我那節改編號成 A19——main 上 A16、A17 已經被 #90#99 各佔一次,不動別人的。

接上 inkstone/ISEP#82 帶進來的 hooks/lib/mainline.py(不自己養第二套判斷):
  · 有標主線 ⇒ 只排除「屬於主線那一條」的票;掛在**別條** milestone 的仍算債
    (那條線現在沒有人在推,正是它躺著的原因)
  · 沒標主線 ⇒ 退回保守做法:掛 open milestone 的一律不領,並在清單上照實說沒標
  · 別條 milestone 已逾期 ⇒ +15「答應過人家的日期已經過了」

測試 40 → 47 條(+7 條全給主線這段),並把 ISEP_COUNTDOWN_STATE_DIR 也隔離:
不然這支測試的結果會取決於「這台機器現在標了哪條主線」。

盤點數字在合併後的樹上重數:
  hooks/*.sh = 54、hooks.json "command": = 71(本 PR 沒新增 hook,兩個都沒變)
  scripts 一層檔案 = 39(本 PR +2)⇒ plugin.json description 37→39
  版本號沒動(0.12.0,總管統一定版)
This commit is contained in:
2026-08-28 00:51:06 +00:00
25 changed files with 2358 additions and 46 deletions
+40 -3
View File
@@ -42,6 +42,16 @@ from datetime import datetime, timezone
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = os.environ.get("ISEP_DEBT_ORG") or "inkstone"
# ── 「現在的主線是哪一個」只有一份答案(inkstone/ISEP#82 → hooks/lib/mainline.py)──
# 本工具排除的是**緊急線正在管的那些**。在 #82 之前,「哪個是 active」沒有指涉對象
# 14 個 open milestone 同時亮著),所以第一版只能粗暴地「掛 open milestone 就排除」。
# 現在有唯一答案了 ⇒ 接上去,**不要自己養第二套判斷**(兩份必然漂移)。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
import mainline as ML
except Exception: # 拿不到共用零件就退回舊行為(fail-open),不讓還債線整個癱掉
ML = None
# 等 leo 的票不進「可領清單」——不是它們不重要,是**沒有人能替他領**:
# Humanhuman/exec 要他親手批示或動手
# s/stage 已經部到 stage,等他驗(session-start-recall 就是這樣定義的)
@@ -214,6 +224,14 @@ def score(it, in_degree):
if it.get("assignees") or it.get("assignee"):
parts.append(("有人接了", 12))
ms = it.get("milestone") or {}
if ms and (ms.get("state") or "open") == "open" and ML is not None:
due = ML.due_of(ms)
if due and due < now():
# 掛在**別條**(非主線)而且期限已經過去的 milestone ⇒ 這是承諾過的日期,
# 而那個日期已經過了。它之所以還在這份清單上,正是因為沒有人在管那條線。
parts.append((f"逾期{int((now() - due).total_seconds() // 86400)}天的承諾", 15))
age = days_since(it.get("updated_at"))
if age >= 1:
parts.append((f"擱置{int(age)}天", round(min(age * 0.6, 30), 1)))
@@ -235,13 +253,23 @@ def pick(items, claimed):
if target in open_refs:
in_degree[target] = in_degree.get(target, 0) + 1
pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items)}
ml = ML.load() if ML is not None else None
pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items),
"other_ms": 0, "mainline": ml}
for it in items:
ref = ref_of(it)
ms = it.get("milestone")
if ms and (ms.get("state") or "open") == "open":
stats["milestone"] += 1 # 緊急線在管,不重複領
continue
if ml is None:
# 現在沒有主線 ⇒ 分不出哪一條是緊急線在管的。退回保守做法:
# 掛 open milestone 的一律不領(寧可少領,不要跟緊急線搶同一張)。
stats["milestone"] += 1
continue
if ML.belongs(ref, ml, ms.get("title")):
stats["milestone"] += 1 # 這張真的在主線上 ⇒ 緊急線在管,不重複領
continue
# 掛在**別條** milestone 上:那條線現在沒有人在推 ⇒ 它就是債
stats["other_ms"] += 1
if labels_of(it) & WAITING_LEO_LABELS:
stats["human"] += 1 # 沒有人能替 leo 領(催辦是 ISEP#93
continue
@@ -376,6 +404,15 @@ def print_proof(repos, stats):
for r in sorted(repos, key=lambda x: -(x["issues"] + x["prs"])):
mark = f"(跳過:{r['skipped']}" if r.get("skipped") else ""
print(f" · {r['name']} issue {r['issues']}PR {r['prs']} {mark}")
ml = stats.get("mainline")
if ml:
print(f"\n🎯 現在的主線:{ML.ref_of(ml)}「{ml.get('title')}」"
f"(唯一答案來自 scripts/mainline,本工具不自己判)")
print(f" 掛在**別條** milestone 上的 {stats['other_ms']} 張仍算債"
"——那條線現在沒有人在推")
else:
print("\n🎯 現在沒有標主線(`scripts/mainline set …`"
"⇒ 分不出哪條是緊急線在管的,保守起見:掛 open milestone 的一律不領")
print(f"\n 扣掉:掛在 open milestone 上的 {stats['milestone']} 張(那些歸緊急線管)"
f"/等 leo 的 {stats['human']} 張"
f"Human・human/exec・s/stage——那些要催不是要領,見 inkstone/ISEP#93"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# gate-ok — 各道閘「總管看過了,放行下一個動作」的**單一入口**inkstone/ISEP#90 ④)
#
# ══ 為什麼要有這支 ═════════════════════════════════════════════════════
#
# 這些閘每一支都留了一道給總管的門,而每一道門長得都不一樣:
# touch /tmp/.prod-write-ok
# git rev-parse --show-toplevel > /tmp/.main-push-ok
# touch /tmp/.stage-verified
# touch /tmp/.solo-ok-<session_id>
# …
# 兩個後果:
# ① **記不住**。門的形狀藏在被擋下的那則訊息裡,每支都不同,
# 而且有兩支還帶參數(綁 repo、綁 session)——照著抄錯一個字,門就打不開。
# ② **放行不了**。逃生口是「一條臨時湊出來的 Bash 指令」,
# 而臨時湊出來的指令**沒有穩定的形狀可以事先放行**:
# `.claude/settings.json` 的 allow 只能逐條寫死
# (現場真的就寫著 `Bash(touch /tmp/.prod-write-ok)` 這種完全比對的一行),
# 多一個 `&&`、換一個檔名、加一個 session id,就落在那條規則之外。
# 2026-08-28inkstone/InkStoneCo#99)總管在雲端連續四次沒能把
# prod-write 的戳記蓋出去 ⇒ **閘留了一道給總管的門,而那道門打不開**
# ⇒ 任何「擋下來、但總管可以放行」的閘,在那個環境裡等於「純擋」。
#
# ⇒ 這支把所有逃生口收斂成**一個名字、一種形狀**:
# bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" <閘名> [參數]
# 一條前綴規則就涵蓋全部,而且以後新增閘不必再動一次設定。
#
# 🔴 這支**不會弱化任何一道閘**:它蓋的戳記跟閘原本認的是同一個檔、同一種語意
# (單次用完即丟、綁 repo、綁 session、有效期都不變)。它換掉的只有「怎麼蓋」。
# 🔴 也**不是給 subagent 用的**:閘擋的是無心不是惡意(見 main-and-prod-push-guard
# 第 76 行)。subagent 照樣造得出戳記,但它得刻意繞過一段明講「不要這樣做」的訊息。
set -eu
usage() {
cat <<'USAGE'
用法:gate-ok <閘名> [參數]
prod-write 改到線上實例(prod-write-guard)——放行下一個動作,用完即消失
main-push [repo 路徑] 推 gitea mainmain-and-prod-push-guard)——綁 repo,預設是 cwd
stage-verified stage 真的驗過了(stage-before-prod-guard)——記得在回覆貼實測輸出
solo <session_id> 這件事真的該自己做(subagent-first-guard)——回覆裡要寫理由
dispatch <session_id> 派工單真的有例外(dispatch-format-guard
ask <session_id> 這題真的是人閘(ask-user-question-guard
kbdb-down KBDB 真的連不上(history-first-guard)——回覆裡要說明
--list 列出目前存在的戳記
🔴 每一枚戳記都是**留痕**:它會出現在指令歷史上,而且多數是單次用完即丟。
蓋之前先確定你真的看過那個動作會改到什麼;蓋完在回覆裡寫一句為什麼。
USAGE
}
stamp() { # stamp <檔> <說明> [內容]
if [ $# -ge 3 ]; then printf '%s\n' "$3" > "$1"; else : >> "$1"; touch "$1"; fi
printf '✅ 已蓋戳記:%s\n %s\n' "$1" "$2"
}
[ $# -ge 1 ] || { usage; exit 2; }
case "$1" in
-h|--help|help) usage; exit 0 ;;
--list)
printf '目前存在的戳記:\n'
ls -la /tmp/.prod-write-ok /tmp/.main-push-ok /tmp/.stage-verified \
/tmp/.kbdb-down /tmp/.solo-ok-* /tmp/.dispatch-ok-* /tmp/.ask-ok-* 2>/dev/null \
|| printf '(一枚都沒有)\n'
exit 0 ;;
esac
GATE="$1"; shift || true
case "$GATE" in
prod-write)
stamp /tmp/.prod-write-ok "只放行**下一個**會改到線上那台的動作,用完即消失(15 分鐘失效)" ;;
main-push)
# 綁 repo:戳記內容要是那個 repo 的 toplevel,閘會拿它跟「push 的目標 repo」比對。
# 🔴 解不出 repo 時**寧可失敗也不蓋空的**——閘明文寫著空內容不算數
# (08-12 那次穿透就是空檔案變成萬用鑰匙),蓋一個註定打不開的戳記
# 只會讓人以為門開了。
D="${1:-$(pwd)}"
TOP="$(git -C "$D" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$TOP" ]; then
printf '❌ %s 不在任何 git repo 裡,解不出要放行哪個 repo。\n' "$D" >&2
printf ' 用法:gate-ok main-push <那個 repo 的路徑>\n' >&2
exit 2
fi
stamp /tmp/.main-push-ok "只放行推 $TOP 的 main,單次、15 分鐘失效" "$TOP" ;;
stage-verified)
stamp /tmp/.stage-verified "stage 驗過了(6 小時)——**記得在回覆裡貼 stage 的實測輸出**" ;;
kbdb-down)
stamp /tmp/.kbdb-down "KBDB 連不上而放行——**回覆裡要說明**,這是留痕不是豁免" ;;
solo|dispatch|ask)
SID="${1:-${CLAUDE_SESSION_ID:-}}"
if [ -z "$SID" ]; then
printf '❌ %s 要綁 session id(閘用它認「是不是同一個 session」)。\n' "$GATE" >&2
printf ' 被擋下的那則訊息裡就有;或 gate-ok %s <session_id>\n' "$GATE" >&2
exit 2
fi
case "$GATE" in
solo) stamp "/tmp/.solo-ok-$SID" "這件事自己做——**回覆裡要寫理由**" ;;
dispatch) stamp "/tmp/.dispatch-ok-$SID" "這張派工單的例外,放行一次" ;;
ask) stamp "/tmp/.ask-ok-$SID" "這題確認是人閘,放行一次" ;;
esac ;;
*)
printf '❌ 不認得的閘名:%s\n\n' "$GATE" >&2
usage >&2
exit 2 ;;
esac
+332
View File
@@ -0,0 +1,332 @@
#!/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」在現場根本沒有指涉對象——
要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。
這支不解「milestone 太多」(跨 repo 同名是既有做法,不動它),
它解的是「**這一刻我在做哪一條**」沒有答案:
mainline 現在的主線是哪一個(= show,唯一答案,離線)
mainline list 有哪些 open milestone 可以選(打網路)
mainline set <owner/repo#id> 把某一個 milestone 標成主線(打網路,寫進 state)
mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始)
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢)
mainline adopt <owner/repo#N> 「補收」:把一張票掛進主線(打網路)
mainline has <owner/repo#N> 這張票在不在主線上(exit 0 =在,1 =不在)
🔴 **`<owner/repo#id>` 的 id 是 milestone 的 id,不是 issue 的號碼。**
兩者在 Gitea 是兩套獨立編號,`mainline list` 印出來的就是可以直接貼的那一個。
── 答案為什麼唯一 ─────────────────────────────────────────────────
主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個名字,錨在一個具體的 milestone 上 ────────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo。所以
**錨**(期限/進度/目標宣告都讀它)是 `owner/repo#id`
**成員判定**看的是「票所屬 milestone 的標題與主線標題相同」(不分 repo)。
兩件事都成立:同名跨 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__))
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 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(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 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("📌 在標之前,每回合的 🎯 那一行也會照樣講「現在沒有主線」,不會編一條出來。")
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))
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 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 cmd_set(argv):
need_net()
if not argv:
die("用法:mainline set <owner/repo#milestone_id>")
owner, repo, mid = parse_ms_ref(argv[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({
"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,
"members": refs,
"set_at": now, "refreshed_at": now,
})
if not ok:
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)))
print()
cmd_show([])
def cmd_clear(argv):
had = ML.load()
ML.clear()
print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
% ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else ""))
# ── refresh ──────────────────────────────────────────────────────────────
def cmd_refresh(argv):
"""SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。"""
ms = ML.load()
if not ms:
return # 沒有主線就沒有東西要更新,安靜結束
d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
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:
refs, _ = collect_members(ms["owner"], ms["title"], ms["repo"])
if refs:
ms["members"] = refs
ML.save(ms)
# ── adopt(補收)──────────────────────────────────────────────────────────
def cmd_adopt(argv):
need_net()
if not argv:
die("用法:mainline adopt <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")
ref = "%s/%s#%d" % (owner, repo, num)
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
ML.save(ms)
print("✅ 補收:%s 已掛進「%s」(%s/%s#%d" % (ref, ms["title"], owner, repo, mid))
# ── 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:
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):
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`;真的是插件事 ⇒ 那是跳線,說清楚再做。"
% (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:])
+32 -1
View File
@@ -19,8 +19,21 @@ ok(){ # $1=條件 $2=說明
}
run(){ # 跑 list(或任何子命令),輸出留在 $OUT,離開碼留在 $RC
# 🔴 ISEP_COUNTDOWN_STATE_DIR 也要指到暫存目錄:主線(hooks/lib/mainline.py)住在那裡,
# 不隔離的話這支測試的結果會取決於「這台機器現在標了哪條主線」——那就不是測試了。
OUT=$(ISEP_DEBT_FIXTURE="$FX" ISEP_DEBT_STATE_DIR="$T/state" ISEP_DEBT_NOW="$NOW" \
python3 "$W" "$@" 2>&1); RC=$?
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" python3 "$W" "$@" 2>&1); RC=$?
}
ML_DIR="$T/mainline"; mkdir -p "$ML_DIR"
set_mainline(){ # $1=主線 milestone 標題(空=沒有主線)
if [ -z "$1" ]; then rm -f "$ML_DIR/mainline.json"; return; fi
python3 - "$ML_DIR/mainline.json" "$1" <<'PY'
import json, sys
path, title = sys.argv[1], sys.argv[2]
json.dump({"ref": "inkstone/A#1", "owner": "inkstone", "repo": "A", "id": 1,
"title": title, "members": [], "due_on": ""}, open(path, "w"))
PY
}
# ── 造資料:一份長得像 Gitea 回應的 fixture ─────────────────────────────────
@@ -38,6 +51,8 @@ def it(repo, n, title, labels=(), ms=None, ms_state="open", assignees=(),
"html_url": f"https://git.uncle6.me/{repo}/issues/{n}"}
if ms:
d["milestone"] = {"title": ms, "state": ms_state}
if ms == "上個 sprint":
d["milestone"]["due_on"] = "2026-08-10T23:59:59Z" # 已經過了 18 天
if pr:
d["pull_request"] = {"merged": False}
return d
@@ -57,6 +72,8 @@ if which == "full":
body="要先解 inkstone/A#1" + "y" * 2000),
# 掛在 open milestone 上=緊急線在管,不該出現
it("inkstone/A", 3, "緊急線的票", ["p/high", "type/bug"], ms="這週出貨"),
# 掛在**別條**(非主線)milestone 上,而且期限已經過了
it("inkstone/A", 8, "別條線的票", ms="上個 sprint", body="q" * 2000),
# milestone 已關但票還開著=孤兒,該出現
it("inkstone/A", 4, "孤兒票", ms="上週", ms_state="closed", body="z" * 2000),
# 等 leo 的三種,都不該出現在可領清單
@@ -125,6 +142,20 @@ ok 'grep -A6 "inkstone/B#30(票)" <<<"$OUT" | grep -q "inkstone/B#31"' '原
ok '! (grep -A6 "inkstone/A#1(票)" <<<"$OUT" | grep -q "同一包")' \
'單向引用不成包(A#2、B#9 都指著 A#1,但 A#1 沒指回來 ⇒ 不能黏成一坨)'
echo "── 接上「現在的主線是哪一個」(inkstone/ISEP#82 的唯一答案)──"
set_mainline ""; FX="$FULL"; run list --top 20
ok 'grep -q "現在沒有標主線" <<<"$OUT"' '沒標主線 ⇒ 照實說,不編一條出來'
ok '! grep -q "inkstone/A#3" <<<"$OUT" && ! grep -q "inkstone/A#8" <<<"$OUT"' \
'沒標主線 ⇒ 保守:掛 open milestone 的一律不領(不跟緊急線搶同一張)'
set_mainline "這週出貨"; run list --top 20
ok 'grep -q "現在的主線" <<<"$OUT" && grep -q "這週出貨" <<<"$OUT"' \
'標了主線 ⇒ 清單上講得出用的是哪一條(判斷來自 scripts/mainline,不是自己算的)'
ok '! grep -q "inkstone/A#3" <<<"$OUT"' '在主線上的票仍然不領(那是緊急線在管的)'
ok 'grep -q "inkstone/A#8" <<<"$OUT"' '掛在別條 milestone 的是債(那條線現在沒人推)'
ok 'grep -q "逾期17天的承諾+15" <<<"$OUT"' '別條 milestone 已逾期 ⇒ 算「答應過人家的」加分'
ok 'grep -q "別條\*\* milestone 上的 1 張仍算債" <<<"$OUT"' '被改判成債的有幾張,講出來'
set_mainline "" # 收回去,後面幾組回到「沒有主線」的世界
echo "── 驗收 2:清單是空的要說話,不能默默結束 ──"
FX="$T/empty.json"; run list
ok '[ "$RC" = 0 ]' '離開碼 0(沒東西可領不是錯誤)'