From 81368f451fc01328fc8d143b0448144eab29088a Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:30 +0000 Subject: [PATCH 1/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88.c?= =?UTF-8?q?laude-plugin/plugin.json=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/plugin.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aac7e32..9fadbbc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:52 支機械閘(67 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、28 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.10.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:53 支機械閘(68 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.11.0", "keywords": [ "inkstone", "guardrails", -- 2.52.0 From be09353330e90c8a160ea33ab28b31ee90b6172e Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:32 +0000 Subject: [PATCH 2/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88do?= =?UTF-8?q?cs/TESTING.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TESTING.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index 8ea71fc..3b7763b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -91,6 +91,40 @@ bash scripts/test-baton-handback-guard.sh - 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略 - 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報 +### A14 — PR 一定要有結論:52 條 +``` +bash hooks/tests/pr-verdict-guard.test.sh +``` +**該看到**:`52 通過 / 0 失敗`。全程離線(走 `PR_VERDICT_FIXTURE`), +**不打 Gitea、不開測試 PR、不留任何測試票**;狀態檔走 `PR_VERDICT_STATE_DIR`。 + +**它在守什麼**(`inkstone/ISEP#81`,2026-08-27 實查):最久的三個 open PR 躺了兩星期 +(`inkstone/arcrun-rag#91` 14 天、`inkstone/Arcrun#116` 14 天、`inkstone/Arcrun#104` 15 天), +而 v0.9.0 的 46 支閘**一支都沒有在管 PR**。票看起來是「已交付」, +東西卻沒進 main ⇒ 沒進版本 ⇒ **leo 手上永遠不會出現它**。 + +**失敗**: +- B 群(⑨⑩⑪,票上那三個真跡)任一紅 ⇒ 今天這個形狀會再發生一次 +- C 群 ⑭⑰ 紅 ⇒ 驗收條件 1/3 沒過:「放著不管」抓不到,或「merge 了分支還在」放行 +- **A 群任何一條紅 ⇒ 誤攔,這比漏擋嚴重**。②③特別重要: + 指派給人/掛 Human 之後還在點名,就是 ISEP v0.6.0 divergence §B4 記過的那個坑 + (「`s/review` 佇列非空即 block」⇒ 佇列本來就不會空 ⇒ **總管永遠停不下來**) +- E 群 ㉙ 紅 ⇒ 閘訊息裡承諾的出路(「把它指派給那個人,本閘立刻不再點名它」)是假的 +- D 群 ㉓ 紅 ⇒ 判準從「識別碼比對」滑成「只要提到票號就放行」,閘會被任何一句話關掉 + +> 📌 **真跡不只在 fixture 裡跑過**:同一支閘打真實 Gitea 也驗過, +> 一次點名 7 個沒有結論的 PR,含票上那三個(15/14/14 天)。 +> 指令:`printf '{"session_id":"x","transcript_path":"/nonexistent"}' | bash hooks/pr-verdict-guard.sh` +> ——**唯讀,不改動任何 PR**。 + +### A15 — 給結論的那支工具自己說得出要打哪幾通 API +``` +python3 scripts/pr-verdict list +python3 scripts/pr-verdict merge inkstone/ISEP#71 --dry-run +``` +**該看到**:`list` 印出 open PR 與「有沒有人被指派」;`merge --dry-run` 印出 +`[dry-run] POST …/pulls/71/merge` 與 `[dry-run] DELETE …/branches/<分支>`,**一通都不真的送出**。 +**失敗**:`--dry-run` 底下出現真實的寫入結果 ⇒ 這支工具沒辦法在按下去之前被檢查。 ### A14 — 每一則回覆都自己說出拖了多久:20 條 ``` bash hooks/tests/countdown-guard.test.sh @@ -324,6 +358,8 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | A3 打 tag 閘 | 總管 | ✅ 3/3 | | **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) | | **A13 戳記證明看過不是跑過** | 總管 | ✅ 17/17(2026-08-27,inkstone/ISEP#72→4873) | +| **A14 PR 一定要有結論** | 總管 | ✅ 52/52(2026-08-28,inkstone/ISEP#81)+真實 Gitea 唯讀重演 | +| **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28) | | **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/20(2026-08-28,inkstone/ISEP#63) | | **A15 發通知不等於部署** | 總管 | ✅ 37/37(2026-08-28,inkstone/ISEP#63) | | A5 搜尋跨 repo | 總管 | ✅ | -- 2.52.0 From 77a2032858a9d516d3fcc16282a50a1aad5e5eda Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:34 +0000 Subject: [PATCH 3/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88do?= =?UTF-8?q?cs/hooks-inventory.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hooks-inventory.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index ef2980a..d7f7a86 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -7,14 +7,16 @@ ## 一句話結論 -`hooks/` 底下有 **52 個 `.sh` 檔**,`hooks.json` 實際掛上 **67 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 52;`grep -c '"command":' hooks/hooks.json` = 67。 +> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。 +> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -38,6 +40,11 @@ > > 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。 > 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。 +> +> 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。 +> 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。 +> 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34** +> (`ls -p scripts | grep -v / | wc -l`)——**同一個病,只是換一欄。** --- @@ -132,6 +139,7 @@ | `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 | | `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 | | `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) | +| `pr-verdict-guard.sh` | 收工時清點「還有哪些 PR 沒有結論」——open、**沒有人被指派**、也沒有「要求修改」review 的 PR,就擋一次並**點名是哪幾個**(含開了幾天與網址);已經 merge 但**分支還留著**的也一起點名(票上寫死「併完當場刪掉那條 branch」)。背景:2026-08-27 實查,最久的三個 open PR 躺了兩星期,而當時 46 支閘沒有一支在管 PR——票看起來「已交付」,東西卻沒進 main、沒進版本,**leo 手上永遠不會出現它**。🔴 **擋的是遺忘,不是等待**:指派給誰(或掛 Human)就立刻不再點名它;響過門檻就加倍(1→4→8→16,每個 PR 各自算)。搭配 `scripts/pr-verdict`(merge/reject/changes 各一個動作)。 | 🛑 擋(響過就退讓) | | `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。 | 🛑 擋(至多攔一次) | | `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 | | `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 | -- 2.52.0 From accedb5898c3f9f0c67d750f15490750c2c63664 Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:37 +0000 Subject: [PATCH 4/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88ho?= =?UTF-8?q?oks/hooks.json=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/hooks.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hooks/hooks.json b/hooks/hooks.json index 53cd6d1..4896338 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -253,6 +253,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pr-verdict-guard.sh" } ] }, -- 2.52.0 From 99abdf725bb9d4f77829061d62e4e3d7b645d5ad Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:39 +0000 Subject: [PATCH 5/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88ho?= =?UTF-8?q?oks/pr-verdict-guard.sh=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/pr-verdict-guard.sh | 394 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 hooks/pr-verdict-guard.sh diff --git a/hooks/pr-verdict-guard.sh b/hooks/pr-verdict-guard.sh new file mode 100644 index 0000000..574fba8 --- /dev/null +++ b/hooks/pr-verdict-guard.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# 管什麼: 收工那一刻清點「我這邊還有沒有 PR 沒給結論」——open 且沒人在管的 PR、 +# 以及已經 merge 但**分支還留著**的 PR。有就擋一次並點名是哪幾個。 +# 為什麼: 2026-08-27 實查,最久的三個 open PR 躺了兩星期(inkstone/arcrun-rag#91、 +# inkstone/Arcrun#116、inkstone/Arcrun#104),而 v0.9.0 的 46 支閘沒有一支在管 PR。 +# 票看起來「已交付」,但東西沒進 main ⇒ 沒進版本 ⇒ leo 手上永遠不會出現它。 +# 誤觸時怎麼關: 直接再送一次即可(擋完就歸零,而且那幾個 PR 的門檻已加倍)。 +# 真的在等別人 ⇒ 把 PR 指派給他(或掛 Human),本閘立刻不再點名它。 +# +# pr-verdict-guard.sh — PR 收斂警察(Stop;inkstone/ISEP#81) +# +# ── 為什麼是 Stop 而不是 PreToolUse ────────────────────────────────── +# PR 堆積不是「某個動作做錯了」,是「某個動作從來沒發生」。 +# 沒有發生的事情攔不到,只能在收工那一刻清點。同 worklist-guard.sh 的形狀。 +# **刻意只掛 Stop,不掛 SubagentStop**:給 PR 結論是總管的事, +# subagent 手上沒有那個權(它連 main 都不准推)⇒ 掛上去只會變成對它的誤攔。 +# +# ── 這支跟 baton-handback-guard.sh 是接力,不是重複 ──────────────── +# baton 管「棒子有沒有交回來」(票的指派+tag+下一步三格)。 +# 本支管「交回來之後,總管有沒有動它」——PR 是那根棒子的實體。 +# +# ── 🔴 已知的坑:這支很容易長成「讓總管永遠停不下來」的那一支 ────── +# ISEP v0.6.0 的 divergence 文件 §B4 記過同款: +# 「`s/review` 佇列非空即 block 總管 stop」⇒ 佇列本來就不會空 ⇒ 永遠擋。 +# ⇒ **要擋的是遺忘,不是等待。** 本閘用三層把「等待」全部放掉: +# +# ① 有人被指派(或掛 Human)⇒ 不點名。 +# 指派是 Gitea 原生欄位、也是本 repo 既有的「現在誰該動」真相 +# (見 baton-handback-guard 檔頭那張三格表)。棒子在誰手上是**事實**, +# 不是措辭 ⇒ 有人拿著 = 在等,不是忘了。 +# ② 有一則 REQUEST_CHANGES 的 review ⇒ 不點名。 +# 「要求修改」這個結論已經給了,球在對方腳下。 +# ③ 這一回合碰過它 ⇒ 不點名(見下面「怎麼算碰過」)。 +# ④ 點名過就退讓:那個 PR 的門檻 1 → 4 → 8 → 16…(每 PR 各自算)。 +# ⇒ 不可能鎖死:擋完歸零,再送一次就過;真的長期不理才會再響。 +# +# ── 怎麼算「碰過」:不讀任何一句話 ────────────────────────────── +# ① `updated_at` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commit/submit review +# 全部會動它)。這是**外部系統的事實**,跟我在對話裡怎麼寫無關。 +# ② 上一次收工時還不存在的 PR ⇒ 剛開出來 ⇒ 碰過(不在它誕生的那一回合就開罵)。 +# ③ 這一回合的 tool call 裡出現它的**識別碼**(`owner/repo#N`/`/pulls/N`/它的網址) +# ⇒ 碰過。這一條是為了接住「結論寫在票上」——票被改了、PR 沒被改, +# ①②都看不到,但那正是本票規定的其中一種合法結論。 +# +# 🔴 ③ 不是關鍵字黑名單(leo 2026-08-17 已證偽那條路:當日 8 次誤攔、0 次正確攔截)。 +# 差別在方向與集合:黑名單拿**措辭**來**擋**(措辭無限,永遠追不完); +# 這裡拿**識別碼**來**放行**(識別碼是有限且唯一的,而且比對錯了只會少擋一次)。 +# 本 repo 心法:只准往放行的方向做字面比對。 +# +# ── merge 了但分支還在 = 結論只給了一半 ──────────────────────── +# 票上寫死:「merge → 併完當場刪掉那條 branch」。所以某個 PR 從 open 清單消失時, +# 本閘去看它是不是 merged、head 分支還在不在;還在就一起點名。 +# (只在「上次還 open、這次不見了」時查,平常一通 API 都不多打。) +# +# ── 內部錯誤不准靜默放行 ──────────────────────────────────────── +# Stop 閘 fail-closed 會鎖死 session,而誤攔是本 repo 的第一級缺陷 +# ⇒ 內部錯誤走 exit 0,但留痕(log + stderr 各一行)。同 mainline-idle-guard.sh。 +# +# 迴歸測試:hooks/tests/pr-verdict-guard.test.sh(離線,走 PR_VERDICT_FIXTURE) +set -u + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)── +TOKEN="" +if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then + for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do + [ -e "$d/.git" ] || continue + U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true + if [ -n "${U:-}" ]; then + T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') + [ "$T" != "$U" ] && { TOKEN="$T"; break; } + fi + done + [ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}" +fi + +# PR_VERDICT_DEBUG=1 時把內部錯誤原文放出來(平常吞掉,避免雜訊蓋掉閘的訊息) +ERRSINK=/dev/null +[ -n "${PR_VERDICT_DEBUG:-}" ] && ERRSINK=/dev/stderr + +# 🔴 payload 走環境變數,不走 stdin——`python3 - <"$ERRSINK" +import json, os, re, sys, time +import urllib.error, urllib.parse, urllib.request + +HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me") +ORGS = [o.strip().lower() for o in + os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()] +FIXTURE = os.environ.get("PR_VERDICT_FIXTURE", "") +TOKEN = os.environ.get("GITEA_TOKEN_RESOLVED", "") + +def out(s): + sys.stdout.write(s) + raise SystemExit + +try: + d = json.loads(os.environ.get("PR_VERDICT_PAYLOAD") or "{}") +except Exception: + out("CRASH:bad-payload") + +# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋,同 mainline-idle-guard) +if d.get("stop_hook_active"): + out("SKIP:already-nudged") + +sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64] +state_path = os.path.join(os.environ.get("PR_VERDICT_STATE_DIR", "/tmp"), + ".pr-verdict-%s.json" % sid) + +# ── 資料來源:正式打 Gitea,測試走 fixture(單一 JSON 檔或目錄下的 state.json)── +FX = None +if FIXTURE: + p = FIXTURE if os.path.isfile(FIXTURE) else os.path.join(FIXTURE, "state.json") + try: + with open(p) as f: + FX = json.load(f) + except Exception: + out("CRASH:bad-fixture") + +def api(path, soft=True): + """GET 一筆。fixture 模式完全不碰網路。失敗回 None(不影響該不該擋的核心判斷)。""" + if FX is not None: + return None + if not TOKEN: + return None + try: + req = urllib.request.Request(HOST + "/api/v1" + path, + headers={"Authorization": "token " + TOKEN}) + return json.load(urllib.request.urlopen(req, timeout=12)) + except urllib.error.HTTPError as e: + return {"__status__": e.code} + except Exception: + return None + +# ── ① 撈 open PR(一通跨 repo 的呼叫)───────────────────────────── +if FX is not None: + pulls = FX.get("pulls", []) +else: + if not TOKEN: + out("SKIP:no-token") + pulls = api("/repos/issues/search?type=pulls&state=open&limit=50") + if pulls is None or isinstance(pulls, dict): + out("SKIP:api-unreachable") + +def ref_of(pr): + return "%s#%s" % (pr.get("repository", {}).get("full_name", "?"), pr.get("number")) + +live = {} +for pr in pulls: + full = (pr.get("repository") or {}).get("full_name") or "" + if "/" not in full: + continue + if ORGS and full.split("/", 1)[0].lower() not in ORGS: + continue # 只管我這邊的 org;舊 org 的鏡像不是我的責任 + if (pr.get("pull_request") or {}).get("draft"): + continue # draft = 作者自己說「還沒好」,不是等我給結論 + live[ref_of(pr)] = pr + +# ── ② 讀上一次收工的快照 ───────────────────────────────────────── +st = {} +try: + with open(state_path) as f: + st = json.load(f) +except Exception: + st = {} +seen = st.get("seen") or {} +leftover = st.get("leftover") or {} # ref -> branch,merge 完分支還沒刪的 +first_run = not seen and "seen" not in st + +# ── ③ 這一回合的 tool call 裡提到了哪些識別碼(只用來放行)────────── +turn_blob = "" +tp = d.get("transcript_path") or "" +if tp and os.path.exists(tp): + try: + rows = [] + with open(tp) as f: + for line in f: + line = line.strip() + if line: + try: + rows.append(json.loads(line)) + except Exception: + pass + start = 0 + for i, r in enumerate(rows): + if r.get("type") == "user": + c = (r.get("message") or {}).get("content") + blocks = c if isinstance(c, list) else [{"type": "text"}] + if not any(isinstance(b, dict) and b.get("type") == "tool_result" + for b in blocks): + start = i + chunks = [] + for r in rows[start:]: + if r.get("type") != "assistant": + continue + for b in (r.get("message") or {}).get("content") or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + chunks.append(json.dumps(b.get("input") or {}, ensure_ascii=False)) + turn_blob = "\n".join(chunks) + except Exception: + turn_blob = "" + +def mentioned(ref): + """這一回合的 tool call 有沒有指名這個 PR。識別碼比對,只用來放行。""" + if not turn_blob: + return False + full, num = ref.split("#") + owner, repo = full.split("/", 1) + pats = ["%s#%s" % (full, num), "%s#%s" % (repo, num), + "/pulls/%s" % num, "/pull/%s" % num] + return any(p in turn_blob for p in pats) + +# ── ④ merge 完分支還在不在(只在「上次還 open、這次不見了」時查)──── +def branch_alive(owner, repo, br): + if FX is not None: + return bool((FX.get("branches") or {}).get("%s/%s:%s" % (owner, repo, br))) + r = api("/repos/%s/%s/branches/%s" % (owner, repo, urllib.parse.quote(br, safe=""))) + if isinstance(r, dict) and r.get("__status__") == 404: + return False + return isinstance(r, dict) and "name" in r + +def pr_detail(ref): + if FX is not None: + return (FX.get("detail") or {}).get(ref) + full, num = ref.split("#") + owner, repo = full.split("/", 1) + r = api("/repos/%s/%s/pulls/%s" % (owner, repo, num)) + return r if isinstance(r, dict) and "__status__" not in r else None + +for ref in list(seen.keys()): + if ref in live: + continue + det = pr_detail(ref) # 不見了:是被 merge 還是被 close? + if not det or not det.get("merged"): + continue + br = ((det.get("head") or {}).get("ref")) or "" + if not br: + continue + full = ref.split("#")[0] + owner, repo = full.split("/", 1) + if branch_alive(owner, repo, br): + leftover[ref] = br + +for ref in list(leftover.keys()): # 已經刪掉的就從清單移除 + full = ref.split("#")[0] + owner, repo = full.split("/", 1) + if not branch_alive(owner, repo, leftover[ref]): + leftover.pop(ref, None) + +# ── ⑤ 逐個 open PR 判「有沒有人在管」────────────────────────────── +def reviews_of(ref): + if FX is not None: + return (FX.get("reviews") or {}).get(ref) or [] + full, num = ref.split("#") + owner, repo = full.split("/", 1) + r = api("/repos/%s/%s/pulls/%s/reviews" % (owner, repo, num)) + return r if isinstance(r, list) else [] + +try: + BASE = max(1, int(os.environ.get("PR_VERDICT_THRESHOLD", "1"))) +except Exception: + BASE = 1 + +overdue, newseen = [], {} +for ref, pr in live.items(): + prev = seen.get(ref) or {} + rec = {"updated_at": pr.get("updated_at"), + "quiet": int(prev.get("quiet", 0) or 0), + "threshold": int(prev.get("threshold", BASE) or BASE), + "fires": int(prev.get("fires", 0) or 0)} + newseen[ref] = rec + + # 有人被指派/掛了 Human = 棒子在某個人手上 ⇒ 在等,不是忘了 + if pr.get("assignees"): + rec["quiet"] = 0 + continue + if any((l.get("name") or "").lower() == "human" for l in (pr.get("labels") or [])): + rec["quiet"] = 0 + continue + # 上一次收工時還不存在 ⇒ 剛開出來,別在它誕生的那一回合就開罵 + if not first_run and ref not in seen: + rec["quiet"] = 0 + continue + # 這一回合真的動過它(外部事實)/指名過它(識別碼) + if prev.get("updated_at") and pr.get("updated_at") != prev.get("updated_at"): + rec["quiet"] = 0 + continue + if mentioned(ref): + rec["quiet"] = 0 + continue + + rec["quiet"] += 1 + if rec["quiet"] < rec["threshold"]: + continue + # 「要求修改」這個結論已經給了 ⇒ 球在對方腳下(只在要開罵時才多打這通) + if any((rv.get("state") or "").upper() == "REQUEST_CHANGES" for rv in reviews_of(ref)): + rec["quiet"] = 0 + continue + + rec["quiet"] = 0 + rec["threshold"] = max(4, rec["threshold"] * 4 if rec["threshold"] == 1 + else rec["threshold"] * 2) + rec["fires"] += 1 + overdue.append((ref, pr)) + +try: + with open(state_path, "w") as f: + json.dump({"seen": newseen, "leftover": leftover, "ts": time.time()}, f) +except Exception: + pass + +if not overdue and not leftover: + out("OK:clean:%d-open" % len(live)) + +def age_days(pr): + try: + t = time.mktime(time.strptime(pr.get("created_at", "")[:19], "%Y-%m-%dT%H:%M:%S")) + return max(0, int((time.time() - t) / 86400)) + except Exception: + return -1 + +lines = [] +if overdue: + lines.append("【沒有結論的 PR】(open、沒有人被指派、也沒有「要求修改」的 review)") + for ref, pr in sorted(overdue, key=lambda x: -age_days(x[1])): + dd = age_days(pr) + lines.append(" ❌ %-28s 開了 %s天 %s" + % (ref, dd if dd >= 0 else "?", (pr.get("title") or "")[:36])) + lines.append(" %s" % ((pr.get("pull_request") or {}).get("html_url") or "")) +if leftover: + lines.append("【merge 了,但分支還留著】(結論只給了一半)") + for ref, br in sorted(leftover.items()): + lines.append(" ❌ %-28s 分支 %s 還在" % (ref, br)) + +out("FIRE::" + json.dumps({"body": "\n".join(lines), + "n": len(overdue) + len(leftover)}, ensure_ascii=False)) +PY +) || VERDICT="CRASH:hook-error" +[ -n "${VERDICT:-}" ] || VERDICT="CRASH:empty" + +STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") +LOGDIR="$PROJ/.claude/hooks" +note() { + if [ -d "$LOGDIR" ]; then + printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/pr-verdict-guard.log" 2>/dev/null || true + fi +} + +case "$VERDICT" in + FIRE::*) + BODY=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["body"])') + N=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["n"])') + note "⛔ 擋下" "$N 個 PR 沒有結論" + cat >&2 < + 退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR + scripts/pr-verdict reject --ticket -F <檔> + 要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES + scripts/pr-verdict changes --ticket -F <檔> + + 撈一次現況:scripts/pr-verdict list + +━━ 如果它本來就在等別人,不是被我忘了 ━━━━━━━━━━━━━━━━━━━━ + **把它指派給那個人**(或掛 Human)——本閘立刻不再點名它。 + 指派是 Gitea 原生欄位,撈一次就看得到「現在誰該動」; + 寫在對話裡的「我在等 X」會捲走,欄位不會。 + + 這一條就是本閘跟「s/review 佇列非空即 block」的分界: + **擋的是遺忘,不是等待。**(ISEP v0.6.0 divergence §B4 記過那個坑) + +━━ 這幾個現在真的不該處理 ━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 直接再送一次即可,本閘已經歸零,而且這幾個 PR 的門檻已加倍 + (1 → 4 → 8 → 16…,每個 PR 各自算)。**不必為了過這道閘去做一件假的動作。** +MSG + exit 2 ;; + CRASH*) + note "⚠️ 內部錯誤" "$VERDICT" + printf '⚠️ pr-verdict-guard 內部錯誤(%s)——本回合未清點 PR,已記入 log。\n' "$VERDICT" >&2 + exit 0 ;; + *) + note "✅ 放行" "$VERDICT" + exit 0 ;; +esac -- 2.52.0 From 16c03e46c1082bc4cfa97f89aea6d8e2300dcff4 Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:40 +0000 Subject: [PATCH 6/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88ho?= =?UTF-8?q?oks/tests/pr-verdict-guard.test.sh=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/tests/pr-verdict-guard.test.sh | 220 +++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 hooks/tests/pr-verdict-guard.test.sh diff --git a/hooks/tests/pr-verdict-guard.test.sh b/hooks/tests/pr-verdict-guard.test.sh new file mode 100644 index 0000000..12a2292 --- /dev/null +++ b/hooks/tests/pr-verdict-guard.test.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# pr-verdict-guard.sh 的迴歸測試(inkstone/ISEP#81) +# +# 兩個方向都要有證據: +# A 群「不該擋」——沒有 PR/有人指派/掛 Human/draft/不在 org/剛開出來/ +# 這回合動過/這回合指名過/已有 REQUEST_CHANGES/已被別的閘擋過/ +# merge 完分支已刪/fixture 壞掉 +# B 群「該擋」 ——沒人管的 open PR、放著不管一回合、merge 了但分支還在、 +# 以及**票上那三個躺了兩週的真跡** +# C 群「訊息承諾的出路真的走得通」——擋完歸零門檻加倍、指派下去立刻閉嘴 +# +# 🔴 全程離線(PR_VERDICT_FIXTURE),不打 Gitea、不留任何測試票/測試 PR。 +# 狀態檔走 PR_VERDICT_STATE_DIR,不碰 /tmp 的正式檔。 +# +# 用法:hooks/tests/pr-verdict-guard.test.sh [hooks/pr-verdict-guard.sh 的路徑] +set -u +HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/pr-verdict-guard.sh}" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0; N=0 +NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) +OLD="2026-08-13T04:34:01Z" + +# fixture <檔名> +# PR 規格:ref|updated|created|assignee|labels|draft +mkfx() { python3 - "$@" <<'PY' +import json, sys, os +path, specs = sys.argv[1], sys.argv[2:] +extra = {} +if os.path.exists(path): + extra = json.load(open(path)) +pulls = [] +for s in specs: + ref, upd, crt, asg, lbl, draft = (s.split("|") + [""] * 6)[:6] + full, num = ref.split("#") + pulls.append({ + "number": int(num), + "title": "測試 PR " + ref, + "created_at": crt, "updated_at": upd, + "repository": {"full_name": full, "id": 1}, + "assignees": [{"login": a} for a in asg.split(",") if a], + "labels": [{"name": n} for n in lbl.split(",") if n], + "pull_request": {"merged": False, "draft": draft == "draft", + "html_url": "https://git.uncle6.me/%s/pulls/%s" % (full, num)}, + }) +extra["pulls"] = pulls +json.dump(extra, open(path, "w"), ensure_ascii=False) +PY +} + +# 加一筆「這個 PR 已經被 merge,head 分支叫 X」的細節,以及分支還在不在 +mkdetail() { python3 - "$@" <<'PY' +import json, sys, os +path, ref, branch, alive = sys.argv[1:5] +d = json.load(open(path)) if os.path.exists(path) else {} +full = ref.split("#")[0] +d.setdefault("detail", {})[ref] = { + "merged": True, "head": {"ref": branch, "repo": {"full_name": full}}} +d.setdefault("branches", {})["%s:%s" % (full, branch)] = (alive == "alive") +json.dump(d, open(path, "w"), ensure_ascii=False) +PY +} + +mkreview() { python3 - "$@" <<'PY' +import json, sys, os +path, ref, state = sys.argv[1:4] +d = json.load(open(path)) if os.path.exists(path) else {} +d.setdefault("reviews", {})[ref] = [{"state": state}] +json.dump(d, open(path, "w"), ensure_ascii=False) +PY +} + +# 造一份「這一回合叫過這些工具、輸入長這樣」的 transcript +mktranscript() { python3 - "$@" <<'PY' +import json, sys +path = sys.argv[1] +with open(path, "w") as f: + f.write(json.dumps({"type": "user", + "message": {"content": [{"type": "text", "text": "繼續"}]}}) + "\n") + blocks = [{"type": "tool_use", "name": "Bash", "id": "t", + "input": {"command": c}} for c in sys.argv[2:]] + f.write(json.dumps({"type": "assistant", "message": {"content": blocks}}, + ensure_ascii=False) + "\n") +PY +} + +# fire <期望 exit> <說明> [transcript] [extra-json] [必須出現的字串] +fire() { + sid="$1"; fx="$2"; want="$3"; desc="$4"; tr="${5:-/nonexistent}"; extra="${6:-}"; must="${7:-}" + N=$((N+1)) + payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra") + out=$(printf '%s' "$payload" | PR_VERDICT_FIXTURE="$fx" PR_VERDICT_STATE_DIR="$TMP" \ + CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1 >/dev/null); rc=$? + ok=1 + [ "$rc" -eq "$want" ] || ok=0 + if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi + if [ "$ok" -eq 1 ]; then + printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ + "${must:+ 且訊息含「$must」}" "$rc" + printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /' + FAIL=$((FAIL+1)) + fi +} + +echo "── A 群:不該擋 ────────────────────────────────────────────" + +FX="$TMP/empty.json"; mkfx "$FX" +fire s-empty "$FX" 0 "① 一個 open PR 都沒有 ⇒ 放行" + +FX="$TMP/assigned.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|Leo||" +fire s-asg "$FX" 0 "② 有人被指派(棒子在他手上=在等,不是忘了)⇒ 不點名" + +FX="$TMP/human.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD||Human,p/high|" +fire s-hum "$FX" 0 "③ 掛了 Human(等 leo 親手做)⇒ 不點名" + +FX="$TMP/draft.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||draft" +fire s-drf "$FX" 0 "④ draft(作者自己說還沒好)⇒ 不點名" + +FX="$TMP/otherorg.json"; mkfx "$FX" "Leo/Arcrun#104|$OLD|$OLD|||" +fire s-org "$FX" 0 "⑤ 不在 inkstone org(舊 org 的鏡像)⇒ 不點名" + +FX="$TMP/review.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||" +mkreview "$FX" "inkstone/Arcrun#104" "REQUEST_CHANGES" +fire s-rev "$FX" 0 "⑥ 已經留了 REQUEST_CHANGES(結論給過了,球在對方腳下)⇒ 不點名" + +FX="$TMP/active.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||" +fire s-act "$FX" 0 "⑦ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不疊擋" \ + /nonexistent ',"stop_hook_active":true' + +fire s-bad "$TMP/does-not-exist.json" 0 "⑧ fixture 讀不到 ⇒ 放行(Stop 閘不准把 session 鎖死)" + +echo "── B 群:該擋 ──────────────────────────────────────────────" + +# 票上那三個真跡(真實 ref、真實開票日) +FX="$TMP/real.json" +mkfx "$FX" \ + "inkstone/arcrun-rag#91|$OLD|$OLD|||" \ + "inkstone/Arcrun#116|2026-08-13T05:24:51Z|2026-08-13T05:24:51Z|||" \ + "inkstone/Arcrun#104|2026-08-12T08:51:06Z|2026-08-12T08:51:06Z|||" +fire s-real "$FX" 2 "⑨ 真跡重演:三個躺了兩週的 PR ⇒ **擋下**" \ + /nonexistent '' 'inkstone/arcrun-rag#91' +FX2="$TMP/real2.json"; cp "$FX" "$FX2" +fire s-real2 "$FX2" 2 "⑩ 同上,訊息要點名 inkstone/Arcrun#116" \ + /nonexistent '' 'inkstone/Arcrun#116' +FX3="$TMP/real3.json"; cp "$FX" "$FX3" +fire s-real3 "$FX3" 2 "⑪ 同上,訊息要點名 inkstone/Arcrun#104" \ + /nonexistent '' 'inkstone/Arcrun#104' + +echo "── C 群:驗收條件 1–3(開著不管 → 擋;merge → 放行;分支要不見)──" + +# 驗收 1:一個新 PR 出現(不在它誕生那回合開罵),下一回合放著不管 ⇒ 擋 +FX="$TMP/ac.json"; mkfx "$FX" +fire s-ac "$FX" 0 "⑫ 基準回合:目前沒有 PR" +mkfx "$FX" "inkstone/ISEP#999|$NOW|$NOW|||" +fire s-ac "$FX" 0 "⑬ PR 剛開出來(上一回合還不存在)⇒ 不在它誕生的回合開罵" +fire s-ac "$FX" 2 "⑭ **驗收 1**:開著放著不管,收工 ⇒ 擋下,且點名它" \ + /nonexistent '' 'inkstone/ISEP#999' + +# 驗收 2+3:merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行 +mkfx "$FX" # #999 從 open 清單消失 +mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone" +fire s-ac "$FX" 0 "⑮ **驗收 2+3**:merge 掉且分支已不存在 ⇒ 放行" + +# 驗收 3 的反面:merge 了但分支還在 = 結論只給了一半 ⇒ 擋 +FX="$TMP/left.json"; mkfx "$FX" "inkstone/ISEP#998|$NOW|$NOW|||" +fire s-left "$FX" 2 "⑯ 先讓 #998 進到快照(沒人管 ⇒ 擋一次)" \ + /nonexistent '' 'inkstone/ISEP#998' +mkfx "$FX" # 消失了 +mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "alive" +fire s-left "$FX" 2 "⑰ **驗收 3**:merge 了但分支 feat/y 還在 ⇒ 擋下並點名分支" \ + /nonexistent '' 'feat/y' +mkfx "$FX"; mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "gone" +fire s-left "$FX" 0 "⑱ 分支刪掉之後 ⇒ 放行(清單會自己清乾淨)" + +echo "── D 群:這一回合碰過它就不點名(不讀任何一句話)──────────────" + +FX="$TMP/touch.json"; mkfx "$FX" "inkstone/ISEP#997|$OLD|$OLD|||" +fire s-t1 "$FX" 2 "⑲ 沒人管 ⇒ 擋一次(建立基準)" +# updated_at 動了 = 外部系統的事實(留言/推 commit/改標籤都會動它) +mkfx "$FX" "inkstone/ISEP#997|$NOW|$OLD|||" +for i in 1 2 3 4 5; do + fire s-t1 "$FX" 0 "⑳ updated_at 變了(這回合真的動過它)第 $i 次 ⇒ 不點名" + mkfx "$FX" "inkstone/ISEP#997|$NOW-$i|$OLD|||" +done + +FX="$TMP/mention.json"; mkfx "$FX" "inkstone/ISEP#996|$OLD|$OLD|||" +TR="$TMP/tr.jsonl" +mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#996 -F /tmp/x.md" +fire s-t2 "$FX" 0 "㉑ 這回合的 tool call 指名了 inkstone/ISEP#996(結論寫在票上)⇒ 不點名" "$TR" +for i in 1 2 3 4; do + fire s-t2 "$FX" 0 "㉒ 同上第 $i 次——指名過就不該累積成擋下" "$TR" +done +mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#111 -F /tmp/x.md" +fire s-t2 "$FX" 2 "㉓ 換成指名**別的**票號 ⇒ 這個 PR 仍然沒被碰 ⇒ 擋" + +echo "── E 群:響過就退讓(訊息裡承諾的出路真的存在)────────────────" + +FX="$TMP/backoff.json"; mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|||" +fire s-bo "$FX" 2 "㉔ 第 1 次 ⇒ 擋(門檻 1)" +for i in 1 2 3; do + fire s-bo "$FX" 0 "㉕ 擋過之後第 $i 個回合——門檻已加倍(1→4),不該再響" +done +fire s-bo "$FX" 2 "㉖ 第 4 個回合 ⇒ 再擋一次(門檻 4)" +for i in 1 2 3 4 5 6 7; do + fire s-bo "$FX" 0 "㉗ 再擋過之後第 $i 個回合——門檻 4→8,不該響" +done +fire s-bo "$FX" 2 "㉘ 第 8 個回合 ⇒ 再擋(證明退讓是加倍不是無限)" + +# 訊息說「把它指派給那個人,本閘立刻不再點名它」——這條出路要真的走得通 +mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|claude-code||" +for i in 1 2 3 4 5 6 7 8 9; do + fire s-bo "$FX" 0 "㉙ 指派下去之後第 $i 個回合 ⇒ 永遠不再點名(訊息承諾的出路成立)" +done + +echo +printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" -eq 0 ] || exit 1 -- 2.52.0 From eb7c3656be95f0a2a04fd75b4c91adfe70a594dd Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:42 +0000 Subject: [PATCH 7/7] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88sc?= =?UTF-8?q?ripts/pr-verdict=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/pr-verdict | 257 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 scripts/pr-verdict diff --git a/scripts/pr-verdict b/scripts/pr-verdict new file mode 100644 index 0000000..87eb7a4 --- /dev/null +++ b/scripts/pr-verdict @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""pr-verdict — 給 PR 一個結論,一個動作把該做的全做完(inkstone/ISEP#81)。 + +leo 的問題(票上原文):「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』, +但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」 + +「有結論」只有三種,缺一不可: + + pr-verdict merge 併 + **當場刪掉那條 branch** + pr-verdict reject --ticket <票> -F <檔> 退回:理由寫**票上** + 關 PR + pr-verdict changes --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} 的 token(remote 沒帶憑證,也沒有 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 [--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} 不是 open(state={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 在別的 repo(fork),本指令不跨 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} --ticket -F <檔> [--dry-run]") + owner, repo, num = parse_ref(argv[0]) + ticket = opt(argv, "--ticket") + if not ticket: + die("🚫 缺 --ticket :結論要寫在**票**上。\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 PR(org:{'/'.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() -- 2.52.0