diff --git a/hooks/milestone-account-guard.sh b/hooks/milestone-account-guard.sh new file mode 100644 index 0000000..d50e22e --- /dev/null +++ b/hooks/milestone-account-guard.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# 管什麼: 直接打 Gitea API 把一個里程碑改成 closed,卻沒有先記帳(估多久/花多久/ +# 差多少/為什麼差),就擋下來。 +# 為什麼: leo 2026-08-27 說那天「拖時間」,但拖了多久、比預計多拖多少、為什麼拖—— +# 一個數字都沒有。偵測器早就有了(mainline-idle-guard/factory-idle-guard), +# **但沒有人把它算成帳**,所以事後只能說「這次比較慢」,下次還是一樣。 +# 誤觸時怎麼關: 走正門 `scripts/milestone-account close ` +# (它會自己算三個數字、自己去 Gitea 的時間軸取證據、然後幫你關)。 +# 真的有理由要繞過去 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。 +# +# milestone-account-guard.sh — 結案要記帳(PreToolUse/Bash;inkstone/ISEP#85) +# +# ── 這支跟 milestone-due-guard.sh 是同一條線的兩端 ──────────────────── +# 那支管**開**里程碑要有真的期限(沒有期限就沒有「估多久」可言)。 +# 這支管**關**里程碑要留下那三個數字。少了任何一端,帳都算不出來。 +# +# ── 判準:三件同時成立才擋,缺一放行 ────────────────────────────── +# ① 指令位置真的有人在發網路請求(curl/wget/python/node/gh) +# ——「講到它」跟「執行它」是兩件事,這是本 repo 已經證明過的判準 +# (release-tag-guard.sh 的 `git tag` 位置比對、strip_heredoc.py 的來由)。 +# ② URL 指名了**某一個**里程碑:`/repos///milestones/<數字>` +# ——沒有數字的(建里程碑、列清單)不是本閘管的形狀。 +# ③ 有寫入動詞(PATCH/POST/.patch(/.post()**而且**內容把 state 設成 closed。 +# +# ⇒ 純 GET、建里程碑、改標題改期限、`cat` 這支腳本、把指令寫進文件(heredoc 內文 +# 先被 strip_heredoc.py 剝掉)、走正門 `scripts/milestone-account close` +# ——全部放行,一條都不擋。 +# +# ── 抽不出 owner/repo/編號就不擋 ──────────────────────────────────── +# fail-open on 解析失敗(不是 fail-open on 檢查結果)。理由同 release-tag-guard.sh: +# 誤攔比漏擋更該修——**誤攔會懲罰謹慎**,而被誤攔幾次之後人就學會繞過這支閘。 +# +# ── 已知的漏擋(寫出來,不假裝沒有)──────────────────────────────── +# `bash -c 'curl -X PATCH …'` 這種把真正的指令包進引號裡的寫法,本閘看不到。 +# 這是刻意的取捨:要接住它就得去猜引號裡的內容,而那條路的盡頭是關鍵字黑名單 +# (leo 2026-08-17 已證偽:當日 8 次誤攔、0 次正確攔截)。 +# +# 迴歸測試:hooks/tests/milestone-account-guard.test.sh(離線,不打 Gitea) +set -uo pipefail + +HOOKDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INPUT=$(cat 2>/dev/null || echo '{}') +CMD=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys +try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") +except Exception: print("") +' 2>/dev/null || printf '') +[ -n "$CMD" ] || exit 0 + +# 明講的豁免(留痕) +case "$CMD" in *milestone-account-ok*) exit 0 ;; esac + +# heredoc 的內文是資料不是指令——先剝掉,理由見 hooks/lib/strip_heredoc.py 檔頭 +if [ -f "$HOOKDIR/lib/strip_heredoc.py" ]; then + CMD=$(printf '%s' "$CMD" | python3 "$HOOKDIR/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD") +fi + +# ① 指令位置真的有人在發請求 +printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*(sudo[[:space:]]+)?(curl|wget|http|python3?|node|gh)[[:space:]]' || exit 0 + +# ② 指名某一個里程碑 +REF=$(printf '%s' "$CMD" | grep -oE '/repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/milestones/[0-9]+' | head -1 || printf '') +[ -n "$REF" ] || exit 0 + +# ③ 寫入動詞 + 把 state 設成 closed +printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|--request[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|\.(patch|post)\(|method[[:space:]]*=[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)' || exit 0 +printf '%s' "$CMD" | grep -qiE 'state[^A-Za-z0-9]{1,8}closed' || exit 0 + +OWNER=$(printf '%s' "$REF" | cut -d/ -f3) +REPO=$(printf '%s' "$REF" | cut -d/ -f4) +NUM=$(printf '%s' "$REF" | cut -d/ -f6) +TARGET="$OWNER/$REPO#$NUM" + +# 已經記過帳的就放行——正門 `milestone-account close` 是**先記帳、再關**, +# 所以「帳本裡有這一筆」就代表三個數字已經留下來了。 +LEDGER="${MILESTONE_ACCOUNT_LEDGER:-}" +if [ -z "$LEDGER" ] && [ -x "$HOOKDIR/../scripts/milestone-account" ]; then + LEDGER=$(python3 "$HOOKDIR/../scripts/milestone-account" ledger-path 2>/dev/null || printf '') +fi +if [ -n "$LEDGER" ] && [ -f "$LEDGER" ] && grep -qF "\"ref\": \"$TARGET\"" "$LEDGER" 2>/dev/null; then + exit 0 +fi + +cat >&2 <] + + 三個數字**它自己算**(取自 Gitea 的 created_at/due_on/closed_at),不用你手填。 + 差超過 ±25%(超時或提早都算)才需要 --reason,而且代號只能從那七個裡面挑: + scripts/milestone-account codes + 代號要有**時間軸撐得住**——證據取自 Gitea,不採信任何人的自述。 + +━━ 為什麼不是「你自己 curl 一下就好」 ━━━━━━━━━━━━━━━━━━━━ + 這一關掉,那個里程碑就再也算不出帳了(closed_at 之後沒有人會回頭補)。 + ⇒ 這是**不可逆**的一步,所以閘長在這裡,不長在誰的記性上。 + + 真的有理由要手動關 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。 +MSG +exit 2