下游做完時頂層跟著關(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
This commit is contained in:
claude-code
2026-08-28 00:31:35 +00:00
parent a9e30fc6e7
commit f3a41fba0e
4 changed files with 626 additions and 6 deletions
+274
View File
@@ -0,0 +1,274 @@
#!/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