併進 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:
+40
-3
@@ -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 的票不進「可領清單」——不是它們不重要,是**沒有人能替他領**:
|
||||
# Human/human/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)"
|
||||
|
||||
Executable
+113
@@ -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-28(inkstone/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 main(main-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
|
||||
Executable
+332
@@ -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:])
|
||||
@@ -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(沒東西可領不是錯誤)'
|
||||
|
||||
Reference in New Issue
Block a user