Files
sdlc/tools/wp-gate.sh
T

226 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# wp-gate.sh — 工作包 PR 閘門:一個工作包的 PR 沒合併,就不准開下一包(供 jsc-sdlc:implement 呼叫)。
#
# 為什麼要有這支腳本:這條規則原本只寫在技能內文裡,靠模型自律遵守。內文靠不住——換一個
# 工作階段、換一個模型,或只是上下文被截掉,規則就跟著消失,而且沒有任何徵兆看得出來。
# 所以判定搬到程式層:未結清的狀態由 jsc-hooks 的 sdlc-gate.sh 記在 $JSC_HOME/wp/ 下(刻意
# 不綁 session,開新對話照樣擋),PR 的真實合併狀態則由本檔向 Gitea 查,查到合併才結清。
#
# 分工:hook 那半(wp-check)只注入提醒並擋掉別的階段技能,它不打網路;本檔是唯一會去問
# Gitea 的一方,也是唯一有權解鎖的一方。兩邊都能改狀態的話,就沒有人說得清鎖為什麼不見了。
#
# 用法:
# wp-gate.sh check {owner}/{repo} {index} [--since {ISO 時間}]
# 查一支 PR。已合併就解鎖並放行;沒合併就把留言全部印出來並擋住。
# --since 只印比該時間更新的留言,值用上一輪印出的 latest=(UTC,形如 2026-08-25T10:19:59Z)。
# wp-gate.sh lock {owner}/{repo} {index}
# PR 開好之後上鎖,讓閘門跨工作階段有效。轉呼叫 sdlc-gate.sh wp-lock。
#
# 輸出: 第一行固定為 `status=...`(供程式判讀),其後為人類可讀的繁中說明。
# status=merged 已合併,鎖已解除,可以挑下一個工作包
# status=open PR 還開著,沒有合併
# status=closed-unmerged PR 被關掉但沒有合併——這不算完成
# status=locked 已記下這筆未結清的 PR
# status=usage 用法錯誤
# status=missing-dep 相依腳本找不到,或這支 PR 查不到
# check 未合併時,最後一行固定為 `latest={最新一筆留言的時間戳}`(一筆留言都沒有就是 `latest=`),
# 供呼叫端寫回分析頁,下一輪拿它當 --since,已處理過的留言就不會再處理一遍。
#
# 結束碼: 0=已合併或無阻擋 1=未合併(擋住,呼叫端必須停下來逐筆修留言)
# 2=用法錯誤 3=相依工具或 PR 查不到
#
# 陷阱:
# - 「state=closed 但 merged=false」最容易被當成完成:那是 PR 被關掉、程式碼沒進去,
# 所以單獨回報 closed-unmerged,結束碼一樣是 1。只看 state 會放行一包沒交出去的工作。
# - 查不到就擋,絕不安靜放行。相依腳本缺一支、或 PR 查不到,一律 exit 3 並講明缺哪一支:
# 閘門查不到卻放行,等於沒有閘門,而且比沒有更糟——大家以為有人在看。
# - 留言讀不到(例如網路中斷)時仍然擋(exit 1),不改判成通過。
# - --since 是字串比較,不做時區換算。Gitea 印的是 UTC 的 ...Z,字典順序等於時間順序;
# 餵進帶 +08:00 這類偏移的值會比錯,所以只餵上一輪的 latest=。
# - latest= 取的是「全部留言」裡最新的一筆,不是過濾後那幾筆。取過濾後的會在沒有新留言時
# 倒退回舊時間戳,下一輪又把處理過的留言全部翻出來。
# - 已合併時會呼叫 sdlc-gate.sh wp-unlock 解鎖;解鎖失敗照樣回報 merged 並 exit 0,但會多印
# 一行警示與手動指令——鎖沒清掉,hook 會一直提醒下去。
set -u
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
plugin_root="${CLAUDE_PLUGIN_ROOT:-$script_dir/..}"
usage() {
cat >&2 <<'EOF'
用法:
wp-gate.sh check {owner}/{repo} {index} [--since {ISO 時間}] 查 PR:合併就解鎖放行,沒合併就印出留言並擋住
wp-gate.sh lock {owner}/{repo} {index} 開完 PR 後上鎖,讓閘門跨工作階段有效
結束碼: 0=已合併或無阻擋 1=未合併(擋住) 2=用法錯誤 3=相依工具或 PR 查不到
EOF
}
# 找相依腳本。兩種版面都要顧到,否則腳本只在其中一種版面下會動:
# 並排存取庫(開發用):{workspace}/sdlc 旁邊就是 {workspace}/gitea、{workspace}/hooks
# 已安裝 plugin:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
# 找不到不回傳路徑,由呼叫端 exit 3;不預設一個猜的路徑,猜錯會變成安靜放行。
resolve_dep() { # $1=並排目錄名(gitea、hooks) $2=plugin 內的相對路徑(tools/gitea.sh)
_name="$1"; _rel="$2"
for _c in "$plugin_root/../$_name/$_rel" "$plugin_root/../jsc-$_name/$_rel"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
_c=$(ls -d "$plugin_root"/../../"jsc-$_name"/*/"$_rel" \
"$plugin_root"/../../"$_name"/*/"$_rel" \
"$HOME"/.claude/plugins/cache/*/"jsc-$_name"/*/"$_rel" 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v "$(basename "$_rel")" 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
resolve_dep gitea tools/gitea.sh
}
sdlc_gate_sh() {
if [ -n "${JSC_HOOKS_DIR:-}" ] && [ -f "$JSC_HOOKS_DIR/sdlc-gate.sh" ]; then
printf '%s\n' "$JSC_HOOKS_DIR/sdlc-gate.sh"; return 0
fi
resolve_dep hooks hooks/sdlc-gate.sh
}
missing_dep() { # $1=缺哪一支的說明
echo 'status=missing-dep'
{
echo "[jsc][工作包閘門][ERR]:$1"
echo '本次不判定,也不放行。閘門查不到卻放行等於沒有閘門,請先修好相依關係再重跑。'
} >&2
exit 3
}
# {owner}/{repo} 格式檢查:剛好一層斜線,前後都不得為空。
valid_repo() { # $1=參數
case "${1:-}" in
*/*/*|/*|*/) return 1 ;;
*/*) return 0 ;;
*) return 1 ;;
esac
}
valid_index() { # $1=參數
case "${1:-}" in
''|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}
sub="${1:-}"
[ -n "$sub" ] || { echo 'status=usage'; usage; exit 2; }
shift
case "$sub" in
check)
repo="${1:-}"; index="${2:-}"
valid_repo "$repo" || { echo 'status=usage'; usage; exit 2; }
valid_index "$index" || { echo 'status=usage'; usage; exit 2; }
shift 2
since=''
while [ "$#" -gt 0 ]; do
case "$1" in
--since)
since="${2:-}"
[ -n "$since" ] || { echo 'status=usage'; usage; exit 2; }
shift 2 ;;
--since=*) since=${1#--since=}
[ -n "$since" ] || { echo 'status=usage'; usage; exit 2; }
shift ;;
*) echo 'status=usage'; usage; exit 2 ;;
esac
done
gitea=$(gitea_sh) || missing_dep "找不到 jsc-gitea 的 tools/gitea.sh。並排版面請確認 {workspace}/gitea 存在,已安裝版面請確認 jsc-gitea plugin 已安裝,或設定 JSC_GITEA_TOOLS 指向它的 tools 目錄。"
if ! st=$(sh "$gitea" pr-status "$repo" "$index" 2>/dev/null) || [ -z "$st" ]; then
missing_dep "查不到 PR $repo 第 $index 號的狀態。請確認存取庫、PR 編號、GITEA_HOST 與 GITEA_TOKEN 都對。"
fi
state=$(printf '%s\n' "$st" | awk '{print $1}')
merged=$(printf '%s\n' "$st" | awk '{print $2}')
# PR 不存在時 gitea.sh 印的是「? none none」並照樣 exit 0。認不得的欄位一律當成查不到,
# 不當成未合併也不當成合併:把打錯的 PR 編號當成「未合併」會讓呼叫端一直修不存在的留言。
case "$state:$merged" in
open:true|open:false|closed:true|closed:false) ;;
*) missing_dep "查不到 PR $repo 第 $index 號(pr-status 回「$st」)。請確認存取庫、PR 編號、GITEA_HOST 與 GITEA_TOKEN 都對。" ;;
esac
if [ "$merged" = 'true' ]; then
echo 'status=merged'
echo "$repo 第 $index 號 PR 已合併,這一包結清了,可以挑下一個工作包。"
gate=$(sdlc_gate_sh) || missing_dep "找不到 jsc-hooks 的 hooks/sdlc-gate.sh,鎖解不掉。並排版面請確認 {workspace}/hooks 存在,或設定 JSC_HOOKS_DIR 指向它的 hooks 目錄。"
# stdin 一定要關掉:sdlc-gate.sh 的 hook 模式會讀標準輸入,管線沒人關閉時整支卡死。
if sh "$gate" wp-unlock "$repo" >/dev/null 2>&1 </dev/null; then
echo "鎖已解除($repo)。"
else
echo "[jsc][工作包閘門][WARN]:鎖解不掉($repo)。PR 確實已合併,但狀態檔還在,hook 會一直提醒。請手動執行:sdlc-gate.sh wp-unlock $repo" >&2
fi
exit 0
fi
if [ "$state" = 'closed' ]; then
echo 'status=closed-unmerged'
echo "$repo 第 $index 號 PR 被關掉了,但沒有合併——程式碼沒進到來源分支,這不算完成。"
echo '要嘛重開這支 PR 並把留言修完,要嘛請使用者裁決;在那之前不得挑下一個工作包。'
else
echo 'status=open'
echo "$repo 第 $index 號 PR 還開著,沒有合併。這一包還沒結清,不得挑下一個工作包。"
fi
echo '下列每一筆留言都要有結果(已修、不需修、修不動)。修不動或純討論、讚美的留言直接忽略:'
echo '不在 PR 上回覆、也不因此停下流程,但要在最終回報裡逐筆列出被忽略的留言與理由。'
if [ -n "$since" ]; then
echo "留言(只列比 $since 更新的;更早的已經處理過):"
else
echo '留言(全部,沒有給 --since):'
fi
tmp=$(mktemp) || missing_dep '建不出暫存檔,無法讀留言。'
if ! sh "$gitea" pr-comments "$repo" "$index" >"$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "[jsc][工作包閘門][WARN]:留言讀不到($repo 第 $index 號)。PR 沒合併這件事不變,照樣擋住;請自行到 PR 頁面確認留言。" >&2
echo 'latest='
exit 1
fi
awk -F'\t' -v since="$since" '
NF == 0 { next }
{ if ($1 > latest) latest = $1 }
since == "" || $1 > since { print }
END { print "latest=" latest }
' "$tmp"
rm -f "$tmp"
exit 1 ;;
lock)
repo="${1:-}"; index="${2:-}"
valid_repo "$repo" || { echo 'status=usage'; usage; exit 2; }
valid_index "$index" || { echo 'status=usage'; usage; exit 2; }
[ "$#" -le 2 ] || { echo 'status=usage'; usage; exit 2; }
gate=$(sdlc_gate_sh) || missing_dep "找不到 jsc-hooks 的 hooks/sdlc-gate.sh,鎖上不了。並排版面請確認 {workspace}/hooks 存在,已安裝版面請確認 jsc-hooks plugin 已安裝,或設定 JSC_HOOKS_DIR 指向它的 hooks 目錄。"
# stdin 一定要關掉,理由同 check 分支的 wp-unlock 呼叫。
if ! out=$(sh "$gate" wp-lock "$repo" "$index" 2>&1 </dev/null); then
echo 'status=missing-dep'
{
echo "[jsc][工作包閘門][ERR]:鎖上不了($repo 第 $index 號)。"
[ -n "$out" ] && printf '%s\n' "$out"
echo '沒記下等於沒鎖,閘門在下一個工作階段就會放行。請修好之後重跑。'
} >&2
exit 3
fi
echo 'status=locked'
echo "$repo 第 $index 號 PR 已記為未結清。合併之後跑 wp-gate.sh check 才會解鎖。"
exit 0 ;;
*)
echo 'status=usage'
echo "[jsc][工作包閘門][ERR]:不認得子命令「$sub」。" >&2
usage
exit 2 ;;
esac