4d2036af72
leo 一天只看兩眼,要在一長串留言裡一眼認出「這則在等我動手」。 機器產生的 ARM 請求第一行是 `🔐 ARM 請求`(機器術語)沒有 👤, 而他手寫的交棒都以 `👤 Leo:` 開頭——規範在人身上、不在機器上。 - scripts/gitea-arm-request.sh:第一行改成 `👤 Leo:回這則、貼下面那組碼,就放行這一次 —— <任務>`(🔐/nonce 照舊, nonce 仍單獨成行,gitea-arm-check 的比對不受影響)。加離線印內文的 ISEP_ARM_PRINT_BODY=1 供測試。 - scripts/ticket handback --to Leo:交棒留言第一行改掛 `👤 Leo:<下一步>`, 🏃 那一塊照舊留著(baton-handback-guard 靠留言有沒有 🏃 判斷交棒了沒, 拔掉會漏擋);交回總管 claude-code 維持第一行 🏃、不掛 👤。 body 抽成純函式 handback_comment_lines 供離線驗。 - 掃過的其他 comment-poster 皆非「要 leo 動手」的閘、未改(decide/subtask/ handoff/triage/compose/claim/pr-verdict/milestone-account/gitea-arm-check/ isep-notify 退路留言,理由記在 docs/hooks-inventory.md #123 note)。 測試:gitea-arm-check.test.sh 16→19、test-ticket-handoff-writeback.sh 49→54; comment-carries-task-guard(29/29)、baton-handback-guard(24/24)無回歸。 版本待總管定。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
290 lines
17 KiB
Bash
Executable File
290 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
||
# 「下游做完時頂層跟著關」的測試(inkstone/ISEP#92)
|
||
#
|
||
# 這支測三件事:
|
||
# ① 判準本身(純函式 is_loose/writeback_plan/journey_label)
|
||
# —— 這三支不打網路,所以「撈得對不對」「回寫該做什麼」測得動,
|
||
# 不必真的開票、關票來驗。
|
||
# ② `subtask`/`handoff` 的參數閘:該擋的擋、齊全的放得過
|
||
# ③ `loose` 這個動詞真的接得上(走到網路那一層才炸)
|
||
#
|
||
# 怎麼在「不真的動任何票」的前提下分辨擋/放行(沿用 test-ticket-where-seen-guard.sh):
|
||
# TICKET_HOST 指到一個連不上的位址(127.0.0.1:9)
|
||
# 離開碼 2 = 閘擋下(die)
|
||
# 離開碼 1 = 閘全部放行,走到網路那一層才炸(Connection refused)
|
||
# 全程離線、不打真實 Gitea、不產生任何票、不關任何票。
|
||
cd "$(dirname "$0")/.." || exit 1
|
||
T=scripts/ticket
|
||
TMP=$(mktemp -d)
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
PASS=0; FAIL=0
|
||
|
||
cat > "$TMP/body.md" <<'MD'
|
||
## 目標
|
||
測試用內文。
|
||
|
||
## 驗收條件
|
||
離開碼 2 = 被擋;離開碼 1 = 放行(走到網路才炸)。
|
||
|
||
## deliverable 類型
|
||
code
|
||
MD
|
||
BAD_BODY="$TMP/bad.md"; echo "沒有模板欄位" > "$BAD_BODY"
|
||
TITLE="身為看頂層票的人,我要下游做完時頂層跟著關,我才不會看到一堆早就做完的票"
|
||
|
||
# ── 純函式探針:把 scripts/ticket 當模組載進來問 ────────────────────────
|
||
probe() { # probe <python 片段> → 印出結果
|
||
python3 - "$1" <<'PY'
|
||
import sys, importlib.util as u
|
||
from importlib.machinery import SourceFileLoader as L
|
||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||
print(eval(sys.argv[1], {"m": m}))
|
||
PY
|
||
}
|
||
|
||
p() { # p <說明> <want> <python 片段>
|
||
got=$(probe "$3" 2>&1)
|
||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||
}
|
||
|
||
O='{"state":"open"}'; C='{"state":"closed"}'
|
||
|
||
echo "── ① is_loose:「下游都關了、自己還開著」是機械事實 ──"
|
||
p "★ open + 下游全關 → 撈得到(票上驗收第 3 條)" "True" "m.is_loose('open',[$C,$C])"
|
||
p "open + 還有一張下游開著 → 不撈(它還在跑)" "False" "m.is_loose('open',[$C,$O])"
|
||
p "open + 從來沒有下游 → 不撈(它根本不是頂層票)" "False" "m.is_loose('open',[])"
|
||
p "closed + 下游全關 → 不撈(自己都關了)" "False" "m.is_loose('closed',[$C,$C])"
|
||
p "open + 下游全開 → 不撈" "False" "m.is_loose('open',[$O,$O])"
|
||
|
||
echo
|
||
echo "── ② writeback_plan:下游關掉之後,頂層票該被怎麼處理 ──"
|
||
p "★ 最後一張下游關掉 → handback(指派回總管+s/review,不默默留著)" \
|
||
"('handback', [])" "m.writeback_plan('open',[$C,$C])"
|
||
p "還有別的下游沒關 → 只記一筆,別假裝它可以收了" \
|
||
"('note', [{'state': 'open'}])" "m.writeback_plan('open',[$C,$O])"
|
||
p "頂層票自己已經關了 → skip" \
|
||
"('skip', [])" "m.writeback_plan('closed',[$C,$C])"
|
||
p "頂層票已關但還有 open 下游 → 一樣 skip(不去吵已關的票)" \
|
||
"('skip', [{'state': 'open'}])" "m.writeback_plan('closed',[$O])"
|
||
p "防禦:頂層票沒有任何下游(實務上到不了這裡)→ 不會炸" \
|
||
"('handback', [])" "m.writeback_plan('open',[])"
|
||
|
||
echo
|
||
echo "── ③ journey_label:只加前綴,不猜名字合不合法 ──"
|
||
p "收件匣 → j/收件匣" "j/收件匣" "m.journey_label('收件匣')"
|
||
p "已經帶前綴 → 不重複加" "j/收件匣" "m.journey_label('j/收件匣')"
|
||
p "前後空白 → 修掉" "j/收件匣" "m.journey_label(' 收件匣 ')"
|
||
|
||
echo
|
||
echo "── ④ subtask/handoff 的參數閘(該擋)──"
|
||
run() { # run <want_rc> <說明> <verb> [參數...]
|
||
want=$1; what=$2; verb=$3; shift 3
|
||
out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x \
|
||
python3 "$T" "$verb" inkstone/ISEP#92 "$@" 2>&1)
|
||
got=$?
|
||
LAST_OUT="$out"
|
||
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=%s got=%s %s\n' "$want" "$got" "$what"
|
||
}
|
||
has() { if printf '%s' "$LAST_OUT" | grep -q "$1"; then PASS=$((PASS+1)); printf ' ✅ '
|
||
else FAIL=$((FAIL+1)); printf ' ❌ '; fi; printf '訊息裡有「%s」 %s\n' "$1" "$2"; }
|
||
|
||
run 2 "缺 --title" subtask -F "$TMP/body.md"
|
||
run 2 "缺 -F" subtask --title "$TITLE"
|
||
run 2 "標題不是 User Story" subtask --title "整理一下票" -F "$TMP/body.md"
|
||
run 2 "內文缺模板欄位" subtask --title "$TITLE" -F "$BAD_BODY"
|
||
run 2 "指派了人卻沒寫 --next" subtask --title "$TITLE" -F "$TMP/body.md" --assign someone
|
||
run 2 "--journey 給了空字串(對不上就不要加這個參數)" \
|
||
subtask --title "$TITLE" -F "$TMP/body.md" --journey ""
|
||
has "不硬湊" "空 journey 的訊息講的是「不硬湊」,不是叫人隨便填一個"
|
||
|
||
echo
|
||
echo "── ⑤ 齊全就放得過(誤攔比漏擋更該修)──"
|
||
run 1 "subtask 齊全 → 走到網路才炸" subtask --title "$TITLE" -F "$TMP/body.md"
|
||
run 1 "★ handoff 是同一個動作的別名,一樣放得過" handoff --title "$TITLE" -F "$TMP/body.md" --to arcrun-rag
|
||
run 1 "帶 --journey 一樣放得過" subtask --title "$TITLE" -F "$TMP/body.md" --journey 收件匣
|
||
run 1 "--assign 有配 --next → 放行" subtask --title "$TITLE" -F "$TMP/body.md" --assign someone --next "先看一眼"
|
||
|
||
echo
|
||
echo "── ⑥ loose 這個動詞接得上 ──"
|
||
out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x python3 "$T" loose 2>&1); got=$?
|
||
if [ "$got" = 1 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=1 got=%s loose 走到網路那一層才炸(動詞有註冊、參數不擋)\n' "$got"
|
||
|
||
out=$(python3 "$T" 2>&1)
|
||
if printf '%s' "$out" | grep -q "ticket loose"; then PASS=$((PASS+1)); printf ' ✅ '
|
||
else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf '不帶參數的說明裡列得出 loose(撈不到的東西等於不存在)\n'
|
||
|
||
echo
|
||
echo "── ⑦ 完工回寫的接線(票上驗收第 2 條)──"
|
||
echo " 把 api() 換成錄音機跑一次 _writeback,驗它到底打了哪些端點、送了什麼。"
|
||
echo " 不打網路、不動任何真票;/blocks 的回應形狀取自 2026-08-28 實查 inkstone/ISEP#75。"
|
||
w() { # w <說明> <want> <python 片段(用 rec 這個錄音清單)>
|
||
got=$(python3 - "$3" <<'PY' 2>&1
|
||
import sys, json, importlib.util as u
|
||
from importlib.machinery import SourceFileLoader as L
|
||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||
|
||
SCEN = json.loads(__import__("os").environ["SCEN"])
|
||
rec = []
|
||
def fake_api(path, payload=None, method=None):
|
||
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
|
||
if path.endswith("/blocks"):
|
||
return SCEN["blocks"]
|
||
if path.endswith("/dependencies"):
|
||
return SCEN["deps"]
|
||
if "/labels" in path and (method or "GET") == "GET":
|
||
return [{"name": n, "id": i} for i, n in enumerate(SCEN.get("labels", []), 1)]
|
||
return {}
|
||
m.api = fake_api
|
||
# _writeback 會把進度印出來——那是給人看的,不是這支測試的判準。
|
||
# 判準是**它打了哪些端點、送了什麼**(rec),所以把它的輸出吞掉。
|
||
import contextlib, io
|
||
buf = io.StringIO()
|
||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||
m._writeback("inkstone", "arcrun-rag", 143, "https://example/pr/1")
|
||
print(eval(sys.argv[1], {"rec": rec, "m": m}))
|
||
PY
|
||
)
|
||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||
}
|
||
|
||
PARENT='{"number":136,"state":"open","title":"頂層票","labels":[{"name":"s/doing"},{"name":"p/high"}],"repository":{"full_name":"inkstone/arcrun-rag"}}'
|
||
LBL='["s/todo","s/doing","s/review","p/high"]'
|
||
|
||
# 情境 A:這是最後一張下游 → 母票要被指派回總管+改 s/review
|
||
export SCEN="{\"blocks\":[$PARENT],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}}],\"labels\":$LBL}"
|
||
w "★ 有去問子票的 /blocks(誰在等我)" "True" \
|
||
"any(p.endswith('/issues/143/blocks') for _,p,_ in rec)"
|
||
w "★ 有在母票的時間軸貼回寫留言" "True" \
|
||
"any(mm=='POST' and p=='/repos/inkstone/arcrun-rag/issues/136/comments' for mm,p,_ in rec)"
|
||
w "★ 留言裡點名了剛關掉的那張下游票" "True" \
|
||
"any('arcrun-rag#143' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
|
||
w "★ 留言裡有交付物連結(不是空口說做完了)" "True" \
|
||
"any('https://example/pr/1' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
|
||
w "★ 留言第一行有身份欄(機器貼的留言一樣要標)" "True" \
|
||
"all((pl or {}).get('body','').startswith('【身份】') for _,p,pl in rec if p.endswith('/136/comments'))"
|
||
w "★ 母票被指派回總管(欄位,不是一句話)" "['claude-code']" \
|
||
"[(pl or {}).get('assignees') for mm,p,pl in rec if mm=='PATCH' and p=='/repos/inkstone/arcrun-rag/issues/136'][0]"
|
||
w "★ 母票的 s/* 換成 s/review,而非 s/* 的標籤留著" "True" \
|
||
"[sorted(pl['labels'])==sorted([3,4]) for mm,p,pl in rec if mm=='PUT' and p.endswith('/136/labels')][0]"
|
||
w "沒有去關母票(關票要有人驗——默默關掉跟默默留著是同一個病)" "False" \
|
||
"any((pl or {}).get('state')=='closed' for _,_,pl in rec)"
|
||
|
||
# 情境 B:母票還有別的下游沒關 → 只記一筆,不准指派、不准改 tag
|
||
export SCEN="{\"blocks\":[$PARENT],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}},{\"state\":\"open\",\"number\":150,\"title\":\"還沒做的\",\"repository\":{\"full_name\":\"inkstone/Arcrun\"}}],\"labels\":$LBL}"
|
||
w "還有下游沒關 → 只貼留言,不指派" "False" "any(mm=='PATCH' for mm,_,_ in rec)"
|
||
w "還有下游沒關 → 不改 tag" "False" "any(mm=='PUT' for mm,_,_ in rec)"
|
||
w "留言裡把還沒關的那張列出來(跨 repo 也要列全稱)" "True" \
|
||
"any('inkstone/Arcrun#150' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
|
||
|
||
# 情境 C:母票自己已經關了 → 完全不動它
|
||
PARENT_CLOSED='{"number":136,"state":"closed","title":"頂層票","labels":[],"repository":{"full_name":"inkstone/arcrun-rag"}}'
|
||
export SCEN="{\"blocks\":[$PARENT_CLOSED],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}}],\"labels\":$LBL}"
|
||
w "母票已關 → 一則留言都不貼(不去吵已關的票)" "False" \
|
||
"any(p.endswith('/136/comments') for _,p,_ in rec)"
|
||
|
||
# 情境 D:這張票沒有任何頂層票在等它 → 不要吵
|
||
export SCEN="{\"blocks\":[],\"deps\":[],\"labels\":$LBL}"
|
||
w "沒有頂層票在等它 → 只問一次 /blocks 就收工" "1" "len(rec)"
|
||
unset SCEN
|
||
|
||
echo
|
||
echo "── ⑧ 雙向連結的接線(票上驗收第 1 條)──"
|
||
echo " 同樣用錄音機跑一次 subtask:驗「兩張票互相看得到對方」不是靠人記得補。"
|
||
b() { # b <說明> <want> <python 片段> [額外 argv...]
|
||
what=$1; want=$2; frag=$3; shift 3
|
||
got=$(ARGS_JSON=$(printf '%s\n' "$@" | python3 -c "import sys,json;print(json.dumps([l.rstrip('\n') for l in sys.stdin if l.strip()!='']))") \
|
||
python3 - "$frag" <<'PY' 2>&1
|
||
import sys, os, json, contextlib, io, importlib.util as u
|
||
from importlib.machinery import SourceFileLoader as L
|
||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||
|
||
rec = []
|
||
def fake_api(path, payload=None, method=None):
|
||
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
|
||
if path.endswith("/labels?limit=100"):
|
||
return [{"name": n, "id": i} for i, n in
|
||
enumerate(["s/todo", "s/review", "p/high", "j/收件匣"], 1)]
|
||
if path.endswith("/dependencies"):
|
||
return [{"state": "open", "number": 900, "title": "子票",
|
||
"repository": {"full_name": "inkstone/arcrun-rag"}}]
|
||
if path == "/repos/inkstone/ISEP/issues":
|
||
return {"number": 900, "html_url": "https://git.uncle6.me/inkstone/ISEP/issues/900"}
|
||
if path == "/repos/inkstone/arcrun-rag/issues":
|
||
return {"number": 900, "html_url": "https://git.uncle6.me/inkstone/arcrun-rag/issues/900"}
|
||
if path == "/repos/inkstone/ISEP/issues/92":
|
||
return {"number": 92, "labels": [{"name": "p/high"}]}
|
||
return {}
|
||
m.api = fake_api
|
||
argv = ["inkstone/ISEP#92"] + json.loads(os.environ["ARGS_JSON"])
|
||
buf = io.StringIO()
|
||
blocked = False
|
||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||
try:
|
||
m.cmd_subtask(argv)
|
||
except SystemExit:
|
||
blocked = True # 被閘擋下也是一種結果,不該讓整支測試靜靜掛掉
|
||
print(eval(sys.argv[1], {"rec": rec, "m": m, "blocked": blocked, "buf": buf.getvalue()}))
|
||
PY
|
||
)
|
||
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=%s got=%s %s\n' "$want" "$got" "$what"
|
||
}
|
||
|
||
A_BASE=(--title "$TITLE" -F "$TMP/body.md" --to arcrun-rag)
|
||
|
||
b "★ 子票內文寫著它的頂層票是誰(下游看得到上游)" "True" \
|
||
"any('Parent: inkstone/ISEP#92' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/arcrun-rag/issues')" \
|
||
"${A_BASE[@]}"
|
||
b "★ 有掛上 Gitea 原生相依(不是只寫在內文裡)" "True" \
|
||
"any(mm=='POST' and p=='/repos/inkstone/ISEP/issues/92/dependencies' for mm,p,pl in rec)" \
|
||
"${A_BASE[@]}"
|
||
b "★ 頂層票的時間軸上有一則指向下游的留言(上游看得到下游)" "True" \
|
||
"any('inkstone/arcrun-rag#900' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
|
||
"${A_BASE[@]}"
|
||
b "★ 那則留言明講「不會因為轉出去就自動關」(第 2 條的預告)" "True" \
|
||
"any('不會因為轉出去就自動關' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
|
||
"${A_BASE[@]}"
|
||
b "沒給 --journey → 留言裡誠實寫「沒有貼」(不硬湊,也不假裝有)" "True" \
|
||
"any('沒有貼' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
|
||
"${A_BASE[@]}"
|
||
b "沒給 --journey → 不去動任何一端的標籤軸(只設子票的 s/*)" "1" \
|
||
"len([1 for mm,p,pl in rec if mm=='PUT' and p.endswith('/labels')])" \
|
||
"${A_BASE[@]}"
|
||
b "★ 給了 --journey → 母子兩端都被貼(只貼一端就聚不起來)" "2" \
|
||
"len([1 for mm,p,pl in rec if mm=='PUT' and p.endswith('/labels') and 4 in pl['labels']])" \
|
||
"${A_BASE[@]}" --journey 收件匣
|
||
b "★ 貼在頂層票上時保留它原本的標籤(不是整組蓋掉)" "True" \
|
||
"any(mm=='PUT' and p=='/repos/inkstone/ISEP/issues/92/labels' and sorted(pl['labels'])==[3,4] for mm,p,pl in rec)" \
|
||
"${A_BASE[@]}" --journey 收件匣
|
||
b "labels.yaml 沒有的旅程名 → 擋下來(要求標籤在場,不是猜名字合不合法)" "True" \
|
||
"blocked" "${A_BASE[@]}" --journey 沒這條旅程
|
||
b "擋下來時直接指回 labels.yaml(不叫人自己猜怎麼修)" "True" \
|
||
"'labels.yaml' in buf" "${A_BASE[@]}" --journey 沒這條旅程
|
||
|
||
echo
|
||
echo "── ⑨ inkstone/ISEP#123:交棒留言的人閘記號(純函式 handback_comment_lines)──"
|
||
echo " --to Leo =要 leo 親手做 ⇒ 第一行掛 👤,他掃一長串留言一眼認得出在等他;"
|
||
echo " 🏃 那一塊照舊留著(baton-handback-guard 靠它判斷交棒了沒)。"
|
||
p "★ --to Leo → 第一行以 👤 開頭(等 leo 動手一眼認得出)" "True" \
|
||
"m.handback_comment_lines('Leo','貼一組碼放行出貨')[0].startswith('👤')"
|
||
p "★ --to Leo → 🏃 仍在留言裡(拔掉它 baton-handback-guard 會漏擋)" "True" \
|
||
"any('🏃' in x for x in m.handback_comment_lines('Leo','貼一組碼放行出貨'))"
|
||
p "--to Leo → 第一行講得出「要做什麼」(下一步就在 👤 那一行)" "True" \
|
||
"'貼一組碼放行出貨' in m.handback_comment_lines('Leo','貼一組碼放行出貨')[0]"
|
||
p "--to claude-code(交回總管)→ 第一行仍是 🏃,不掛 👤(那不是等 leo 的)" "True" \
|
||
"m.handback_comment_lines('claude-code','去驗那一格')[0].startswith('🏃') and not any('👤' in x for x in m.handback_comment_lines('claude-code','去驗那一格'))"
|
||
p "證據與未關相依照舊接在後面(沒被人閘記號擠掉)" "True" \
|
||
"any('證據' in x for x in m.handback_comment_lines('Leo','n',ev='http://x')) and any('未關的相依' in x for x in m.handback_comment_lines('Leo','n',openn=[{'repository':{'full_name':'inkstone/ISEP'},'number':9,'title':'t'}]))"
|
||
|
||
echo
|
||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||
[ "$FAIL" = 0 ] || exit 1
|