f3a41fba0e
票上的病:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著, 而 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
275 lines
15 KiB
Bash
Executable File
275 lines
15 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 "通過 $PASS 條,失敗 $FAIL 條"
|
||
[ "$FAIL" = 0 ] || exit 1
|