Files
ISEP/scripts/test-ticket-handoff-writeback.sh
T
claude-code f3a41fba0e 下游做完時頂層跟著關(inkstone/ISEP#92)
票上的病:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,
而 leo 是看頂層的。」缺的三格是 journey 標籤、雙向連結的強制、完工回寫。

不重造既有的(票上明列):`ticket where` 戳記+側門閘(查票防重複)、
`comment-carries-task-guard`(留言藏任務)、Gitea 原生 issue dependency
(跨 repo 關聯的載體)——這一版全部長在它們上面。

四格,判準全部是機械事實(相依邊在不在、state 是什麼),沒有一格在猜文字:

① 雙向連結(驗收 1):`subtask`/新別名 `handoff` 一個動作同時掛原生相依
   + 在頂層票的時間軸貼一則指回下游的留言。相依邊只長在側欄,
   時間軸上什麼都沒有,跨 repo 時尤其看不出來。

② 完工回寫(驗收 2,最痛的那格):`ticket close` 關完子票立刻問 `/blocks`
   (誰在等我),對每張還開著的頂層票貼回寫留言;下游全關的那一張再
   指派回總管+改 s/review。**不自動關母票**——關票要有交付物、要有人看過,
   默默關掉跟默默留著是同一個病的兩面。

③ journey 標籤:`--journey <名>` 把 `j/<名>` 同時貼在母子兩端(只貼一端就
   聚不起來)。標籤不存在就擋下並指回 labels.yaml——要求標籤在場,
   不是猜哪些名字合法。labels.yaml 的 `j/` 段刻意留空:旅程怎麼切、叫什麼
   名字是方向題,不由工具代決。

④ 撈得出來(驗收 3、4):新動詞 `ticket loose` 掃 org 的 open 票,撈
   「有下游相依、下游全關、自己還開著」的頂層票。這是 ② 的補網——
   關票的側門(直接 PATCH state=closed)是刻意放行的,漏掉的由這張網撈回來。
   不是輪詢,掛在開場/收工對帳跑一次。

實跑(2026-08-28,唯讀,沒動任何票):245 張 open 票裡 17 張有下游相依,
其中 2 張是「下游都關了、自己還開著」——inkstone/arcrun-rag#136(已 s/review
且指派給 claude-code,正在被處理)與 inkstone/Arcrun#100(沒標籤、沒指派,
就是票上講的那種「永遠掛著」)。

測試:scripts/test-ticket-handoff-writeback.sh,49 條全綠,全程離線
(會打 API 的兩段把 api() 換成錄音機,不開票不關票不留測試票)。
回歸:A13 17/17、A4 24/24、A12 11/11 皆綠。
文件:docs/TESTING.md 新增 A16。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 00:31:35 +00:00

275 lines
15 KiB
Bash
Executable File
Raw Permalink 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.
#!/bin/bash
# 「下游做完時頂層跟著關」的測試(inkstone/ISEP#92
#
# 這支測三件事:
# ① 判準本身(純函式 is_loosewriteback_planjourney_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 "── ④ subtaskhandoff 的參數閘(該擋)──"
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 "通過 $PASS 條,失敗 $FAIL 條"
[ "$FAIL" = 0 ] || exit 1