#!/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 → 印出結果 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 <說明> 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=$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 <說明> 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 <說明> [額外 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