併進 main(#96 雲端接線/#100 v0.12.0/#99 主線唯一),解 TESTING.md 撞號(inkstone/ISEP#92)

唯一衝突在 docs/TESTING.md:兩條線各自加了一節、**都叫 A16**。
兩邊內容都留,我這一節改成 A19(main 已用掉 A16/A17/A18),
「目前狀態」表的那一列跟著搬到 A18 後面。

main 側本來就有的 A11/A12/A14/A15 重號**沒有動**——那是它兩條線各自編的,
不是這次造成的,動它會把 diff 炸開。

沒有碰的:.claude-plugin/plugin.json(維持 main 的 v0.12.0,版本由總管統一定)、
hooks/hooks.json、docs/hooks-inventory.md——這三個檔本線一個字都沒改,
自動合併拿到的就是 main 那份。

盤點數字在合併後的樹上重數(不是相加、不是抄別人的):
  hooks/*.sh 54 hooks.json 註冊 71 commands 7 skills 2 scripts 頂層 38
docs/hooks-inventory.md 寫的 54/71 **是對的**;README 寫的 48/59/23
**三個都錯**,已改成實數,並把「怎麼重數」的指令寫進去——
這種數字會漂是因為沒人知道它怎麼來的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
This commit is contained in:
claude-code
2026-08-28 00:48:49 +00:00
27 changed files with 3212 additions and 40 deletions
+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:])
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""pr-verdict — 給 PR 一個結論,一個動作把該做的全做完(inkstone/ISEP#81)。
leo 的問題(票上原文):「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
「有結論」只有三種,缺一不可:
pr-verdict merge <owner/repo#N> 併 **當場刪掉那條 branch**
pr-verdict reject <owner/repo#N> --ticket <票> -F <檔> 退回:理由寫**票上** + 關 PR
pr-verdict changes <owner/repo#N> --ticket <票> -F <檔> 要求修改:寫**票上** REQUEST_CHANGES
pr-verdict list 撈一次:誰還沒有結論
🔴 為什麼要有這支、而不是叫人記得多做一步:
「merge 完要刪 branch」「退回理由要寫在票上」都是**第二個動作**,
而所有掉棒的形狀都長一樣——第一個動作做了,第二個沒有。
把兩件事綁成一個指令,就沒有「忘了做第二件」這個狀態可以存在。
🔴 `--dry-run` 會把要打的每一通 API 印出來但不送出。
加在任何子指令上都有效;**收工前想確認自己按下去會發生什麼,用它。**
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
DRY = False
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
die(f"🔴 拿不到 {host} 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE")
def api(path, payload=None, method=None, soft=False):
verb = method or ("POST" if payload is not None else "GET")
if DRY and verb != "GET":
print(f" [dry-run] {verb} {path}"
+ (f"\n {json.dumps(payload, ensure_ascii=False)}" if payload else ""))
return {}
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url, data=data, method=verb,
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try:
raw = urllib.request.urlopen(req, timeout=40).read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
if soft:
return {"__status__": e.code}
die(f"🔴 Gitea {e.code} on {verb} {path}{e.read().decode()[:300]}")
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die(f"🔴 寫法是 owner/repo#N,你給的是:{s}")
return m.group(1), m.group(2), int(m.group(3))
def read_body(argv, what):
if "-F" not in argv:
die(f"🚫 缺 -F <檔>{what}要**寫在票上**,不是寫在訊息裡。\n"
f" inkstone/ISEP#81:「退回理由寫在票上(寫票,不是寫訊息)」)")
p = argv[argv.index("-F") + 1]
try:
with open(p) as f:
return f.read().strip()
except Exception as e:
die(f"🔴 讀不到 {p}{e}")
def opt(argv, name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
def say_on_ticket(ref, body):
"""把結論貼進**票**(不是貼進 PR 的討論串——PR 會隨自己沉掉,票才是那條線的家)。
內文第一行帶【總管】身份欄,對齊 reply-identity-guard 的規約
leo 2026-08-27:「subagent 回覆時要表明身份」)。
"""
owner, repo, num = parse_ref(ref)
if DRY:
print(f" [dry-run] POST /repos/{owner}/{repo}/issues/{num}/comments")
print(f" {body[:120]}…")
return
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
# ── merge ────────────────────────────────────────────────────────────────
def cmd_merge(argv):
if not argv:
die("用法:pr-verdict merge <owner/repo#N> [--style merge|squash|rebase] [--dry-run]")
owner, repo, num = parse_ref(argv[0])
style = opt(argv, "--style", "merge")
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
branch = ((pr.get("head") or {}).get("ref")) or ""
same_repo = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
if not pr.get("merged"):
if pr.get("state") != "open":
die(f"🔴 {owner}/{repo}#{num} 不是 openstate={pr.get('state')})——沒得 merge。")
api(f"/repos/{owner}/{repo}/pulls/{num}/merge",
{"Do": style, "delete_branch_after_merge": True})
if not DRY:
print(f"✅ {owner}/{repo}#{num} 已 merge{style}")
else:
print(f"️ {owner}/{repo}#{num} 本來就是 merged,只補刪分支")
# 🔴 第二件事:**當場複驗分支真的不見了**。
# Gitea 的 delete_branch_after_merge 在某些設定下不會生效(保護分支、
# 跨 fork),而「我下了刪除的指令」不等於「它被刪掉了」——
# 本 repo 心法:交出去之前,我知不知道它能不能用。
if not branch:
print("⚠️ 拿不到 head 分支名,無法複驗——請自己確認分支已刪。")
return
if not same_repo:
print(f"️ head 在別的 repofork),本指令不跨 repo 刪分支:{branch}")
return
if DRY:
print(f" [dry-run] DELETE /repos/{owner}/{repo}/branches/{branch}(若還在)")
return
if _branch_alive(owner, repo, branch):
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(branch, safe='')}",
method="DELETE", soft=True)
if _branch_alive(owner, repo, branch):
die(f"🔴 分支 {branch} 刪不掉(保護分支?權限?)——**結論只給了一半**,"
f"pr-verdict-guard 會繼續點名它。")
print(f"✅ 分支 {branch} 已刪除(複驗過:查不到了)")
def _branch_alive(owner, repo, br):
r = api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}", soft=True)
return isinstance(r, dict) and "name" in r
# ── reject / changes ─────────────────────────────────────────────────────
def _verdict_with_reason(argv, kind):
if not argv:
die(f"用法:pr-verdict {kind} <owner/repo#N> --ticket <owner/repo#N> -F <檔> [--dry-run]")
owner, repo, num = parse_ref(argv[0])
ticket = opt(argv, "--ticket")
if not ticket:
die("🚫 缺 --ticket <owner/repo#N>:結論要寫在**票**上。\n"
" PR 的討論串會隨 PR 一起沉掉;票才是那條線的家。")
body = read_body(argv, "退回理由" if kind == "reject" else "修改要求")
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
head = f"{owner}/{repo}#{num}"
url = (pr.get("html_url") or "")
title = "退回" if kind == "reject" else "要求修改"
say_on_ticket(ticket, f"【總管】**PR {title}**{head} {url}\n\n{body}")
if not DRY:
print(f"✅ {title}理由已寫進票 {ticket}")
if kind == "reject":
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
if not DRY:
print(f"✅ {head} 已關閉")
br = ((pr.get("head") or {}).get("ref")) or ""
same = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
if br and same and not DRY and _branch_alive(owner, repo, br):
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}",
method="DELETE", soft=True)
print(f"✅ 分支 {br} 已刪除")
else:
# REQUEST_CHANGES**讓「已經給過結論」變成 Gitea 查得到的事實**,
# 這樣 pr-verdict-guard 就不會再把它當成被遺忘的 PR(=擋等待)。
api(f"/repos/{owner}/{repo}/pulls/{num}/reviews",
{"event": "REQUEST_CHANGES", "body": f"修改要求寫在 {ticket}"})
if not DRY:
print(f"✅ {head} 已留下 REQUEST_CHANGES review(閘看得到,不會再點名它)")
if DRY:
print("\n[dry-run] 以上一通都沒有送出。")
return
print("\n📌 兩件事是同一個動作——不會只寫了理由而 PR 還開著"
"reject),也不會留了 review 而票上查不到理由(changes)。")
# ── list ─────────────────────────────────────────────────────────────────
def cmd_list(argv):
orgs = [o.strip().lower() for o in
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
rows = api("/repos/issues/search?type=pulls&state=open&limit=50")
import time
now = time.time()
n = 0
for pr in rows:
full = (pr.get("repository") or {}).get("full_name") or ""
if "/" not in full or (orgs and full.split("/", 1)[0].lower() not in orgs):
continue
try:
t = time.mktime(time.strptime(pr["created_at"][:19], "%Y-%m-%dT%H:%M:%S"))
age = int((now - t) / 86400)
except Exception:
age = -1
who = [a["login"] for a in (pr.get("assignees") or [])]
mark = "🕒" if who else "❌"
print(f"{mark} {full}#{pr['number']:<5} 開了 {age:>3} 天 "
f"{'指派=' + ','.join(who) if who else '沒有人被指派'} {pr['title'][:34]}")
n += 1
print(f"\n共 {n} 個 open PRorg{'/'.join(orgs) or '全部'})。"
f" ❌=沒有人在管,pr-verdict-guard 會點名它")
def main():
global DRY
argv = sys.argv[1:]
if "--dry-run" in argv:
DRY = True
argv.remove("--dry-run")
if not argv:
print(__doc__)
sys.exit(0)
cmd, rest = argv[0], argv[1:]
if cmd == "merge":
cmd_merge(rest)
elif cmd == "reject":
_verdict_with_reason(rest, "reject")
elif cmd == "changes":
_verdict_with_reason(rest, "changes")
elif cmd == "list":
cmd_list(rest)
else:
print(__doc__)
die(f"🔴 沒有這個子指令:{cmd}")
if __name__ == "__main__":
main()