#!/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