#!/bin/bash # 雲端工人抓票規則的測試(inkstone/ISEP#131) # # 票上的驗收第 1 條:「`scripts/ticket pick` 給定主線檔,回一張符合規則的票; # 池子裡塞一張 `Human`、一張有 assignee、一張 backlog,三張都不會被挑到(測試綠)。」 # # 這支測四件事: # ① `pickable()` 純函式——規則本體,一格一條,不打網路 # ② `pick` 走整條路(把 api() 換成錄音機餵一個假池子):挑到誰、順序、主線抓盡才抓逾期、 # 沒有可抓的票時離開碼 1、沒有主線時離開碼 2 且訊息講得出出路 # ③ `claim` 一個動作三件事(指派/s/doing/【身份】留言),以及該擋的擋在**打 API 之前** # ④ 不該擋的(誤攔比漏擋更該修) # # 全程離線:假池子在下面的 POOL;TICKET_HOST 指到連不上的位址,任何一條真的走到網路會當場炸, # 不會靜靜變成假綠。不開票、不改票、不留任何東西在 Gitea 上。 cd "$(dirname "$0")/.." || exit 1 T=scripts/ticket TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0 export TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR" export ISEP_COUNTDOWN_NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,9,7,0,0,tzinfo=d.timezone.utc).timestamp()))') unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR MAIN="$TMP/mainline.json" cat > "$MAIN" <<'JSON' {"ref":"inkstone/Arcrun#48","owner":"inkstone","repo":"Arcrun","id":48, "title":"主線 M","description":"測試用主線","due_on":"2026-09-12T23:59:59Z", "open_issues":3,"closed_issues":0,"members":[],"set_at":"2026-09-07 07:30"} JSON # ── 假池子:三個 repo、三個 open milestone、十一張票 ───────────────────── # 主線 M(未逾期,主線)/逾期 M(2026-08-19 到期)/未來 M(2026-10-01 到期,不是主線) cat > "$TMP/pool.py" <<'PY' import json, os MS = {"main": {"id": 48, "title": "主線 M", "due_on": "2026-09-12T23:59:59Z"}, "late": {"id": 49, "title": "逾期 M", "due_on": "2026-08-19T23:59:59Z"}, "future": {"id": 50, "title": "未來 M", "due_on": "2026-10-01T23:59:59Z"}} def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr=False, title=None): d = {"number": n, "title": title or f"票 {repo}#{n}", "state": "open", "labels": [{"name": l} for l in labels], "assignees": [{"login": a} for a in assignees], "milestone": MS[ms] if ms else None, "created_at": created, "repository": {"full_name": f"inkstone/{repo}"}} if pr: d["pull_request"] = {"merged": False} return d POOL = { "ISEP": [I("ISEP", 1, ["s/todo"], "main"), # ★ 可抓 I("ISEP", 2, ["s/todo", "Human"], "main"), # 票上第 1 條:Human I("ISEP", 3, ["s/todo"], "main", assignees=("someone",)), # 票上第 1 條:有 assignee I("ISEP", 4, ["s/backlog"], None), # 票上第 1 條:backlog I("ISEP", 5, ["s/todo"], None), # 沒里程碑 I("ISEP", 10, ["s/todo"], "main", pr=True), # PR 不是票 I("ISEP", 11, ["s/todo", "human/exec"], "main")], # 執行者是人 "Arcrun": [I("Arcrun", 6, ["s/todo", "p/high"], "main", created="2026-08-27T00:00:00Z"), # ★ 主線裡最優先 I("Arcrun", 7, ["s/todo"], "late"), # 逾期里程碑:主線抓盡才輪到 I("Arcrun", 8, ["s/todo"], "future")], # 不是主線也沒逾期:永遠不抓 "mira": [I("mira", 9, ["s/todo", "s/doing"], "main")], # 09-07 實查 mira#6 的形狀:有人在做 } MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]} DROP = set(json.loads(os.environ.get("DROP", "[]"))) # 測「主線抓盡」用:把某幾張從池子拿掉 def fake_api(path, payload=None, method=None): rec.append((method or ("POST" if payload is not None else "GET"), path, payload)) if path.startswith("/orgs/inkstone/repos"): return [{"name": r} for r in POOL] if path == "/user": return {"login": "claude-code"} if "/milestones?" in path: repo = path.split("/")[3] return [MS[k] for k in MSN.get(repo, [])] if "/issues?" in path: repo = path.split("/")[3] import urllib.parse q = urllib.parse.parse_qs(path.split("?", 1)[1]) want = q.get("milestones", [""])[0] return [i for i in POOL[repo] if (i["milestone"] or {}).get("title") == want and f"{repo}#{i['number']}" not in DROP] if path.endswith("/labels?limit=100"): return [{"name": n, "id": k} for k, n in enumerate(["s/todo", "s/doing", "s/review", "p/high", "Human"], 1)] import re m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)$", path) if m and (method or "GET") == "GET": return next(i for i in POOL[m.group(1)] if i["number"] == int(m.group(2))) return {} rec = [] PY # probe —— 把 scripts/ticket 當模組載進來、api() 換成假池子,印出片段的值 probe() { python3 - "$1" "$2" <<'PY' import sys, os, json, io, contextlib, 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) ns = {} exec(open(os.environ["POOL_PY"]).read(), ns) m.api = ns["fake_api"]; rec = ns["rec"] argv = json.loads(sys.argv[2]) if sys.argv[2] else None buf = io.StringIO(); rc = None if argv is not None: with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): try: getattr(m, argv[0])(argv[1:]) except SystemExit as e: rc = e.code print(eval(sys.argv[1], {"m": m, "rec": rec, "rc": rc, "out": buf.getvalue(), "I": ns["I"], "json": json})) PY } export POOL_PY="$TMP/pool.py" p() { # p <說明> [argv json] got=$(probe "$3" "${4:-}" 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" } echo "── ① pickable():規則本體,一格一條(純函式,不打網路)──" p "★ s/todo+沒人+沒 Human+主線 → 可抓" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),'主線 M')[0]" p "★ 掛 Human → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[0]" p "★ 有 assignee → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',3,['s/todo'],'main',assignees=('x',)),'主線 M')[0]" p "★ s/backlog、沒里程碑 → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',4,['s/backlog'],None),'主線 M')[0]" p "s/todo 但沒里程碑 → 不可抓(不抓沒里程碑的)" "False" "m.pickable(I('ISEP',5,['s/todo'],None),'主線 M')[0]" p "human/exec(執行者是人)→ 不可抓" "False" "m.pickable(I('ISEP',11,['s/todo','human/exec'],'main'),'主線 M')[0]" p "s/todo+s/doing 同時掛著(mira#6 的形狀)→ 不可抓,有人在做" "False" "m.pickable(I('mira',9,['s/todo','s/doing'],'main'),'主線 M')[0]" p "hub(scope 容器,09-07 實查 ISEP#31–35 的形狀)→ 不可抓" "False" "m.pickable(I('ISEP',12,['s/todo','hub'],'main'),'主線 M')[0]" p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]" p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]" p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]" p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]" p "順序:p/high 先於沒標,沒標先於 p/low" "True" \ "m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))" p "順序:同優先序看開票時間,舊的先" "True" \ "m.pick_order(I('a',9,['s/todo'],'main',created='2026-08-01T00:00:00Z')) < m.pick_order(I('a',1,['s/todo'],'main',created='2026-09-01T00:00:00Z'))" echo echo "── ② pick 走整條路(假池子 11 張,三個 repo)──" ARGS="[\"cmd_pick\",\"--mainline\",\"$MAIN\"]" ALL="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--all\",\"--json\"]" p "★ 給定主線檔 → 回一張,而且是主線裡最優先的 Arcrun#6(p/high)" "True" "'inkstone/Arcrun#6' in out and rc is None" "$ARGS" p "只回一張(不是整份清單)" "1" "out.count(' ● ')" "$ARGS" p "★ --all:主線裡可抓的恰好是 Arcrun#6、ISEP#1 兩張——Human/assignee/backlog 三張都不在" \ "['inkstone/Arcrun#6', 'inkstone/ISEP#1']" "[r['ref'] for r in json.loads(out) if r['group']=='主線']" "$ALL" p "★ --all:逾期里程碑那張 Arcrun#7 排在主線之後(主線抓盡才輪到)" \ "['inkstone/Arcrun#7']" "[r['ref'] for r in json.loads(out) if r['group']!='主線']" "$ALL" p "沒逾期也不是主線的 Arcrun#8 不在清單裡" "False" "'inkstone/Arcrun#8' in out" "$ALL" p "backlog(ISEP#4)/沒里程碑(ISEP#5)不在清單裡" "False" "'ISEP#4' in out or 'ISEP#5' in out" "$ALL" p "沒有任何一條真的走到網路(api 全被錄音機接住)" "True" "all(p.startswith('/') for _,p,_ in rec)" "$ALL" p "pick 只讀不寫(沒有 POST/PATCH/PUT)" "True" "all(mm=='GET' for mm,_,_ in rec)" "$ALL" p "訊息印出下一步(認領那一行用絕對路徑,貼到別的 cwd 也叫得到正本)" "True" "'claim inkstone/Arcrun#6' in out and m.self_path() in out" "$ARGS" echo echo " (主線抓盡 → 才輪到逾期里程碑)" export DROP='["Arcrun#6","ISEP#1"]' p "★ 主線兩張都被拿走後 → 回逾期里程碑的 Arcrun#7" "True" "'inkstone/Arcrun#7' in out and rc is None" "$ARGS" export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]' p "★ 主線與逾期都抓盡 → 離開碼 1(真的沒有,不是故障)" "1" "rc" "$ARGS" p "訊息明講「不要去抓 backlog」(出路是收工回報,不是換規則)" "True" "'backlog' in out and '收工' in out" "$ARGS" p "抓盡時**沒有**回 Arcrun#8(未來里程碑)" "False" "'Arcrun#8' in out" "$ARGS" unset DROP echo echo " (沒有主線 ≠ 去抓別的)" NOML="[\"cmd_pick\",\"--mainline\",\"$TMP/none.json\"]" p "主線檔讀不到 → 離開碼 2(讀不到 ≠ 沒有)" "2" "rc" "$NOML" p "…而且一通 API 都沒打" "0" "len(rec)" "$NOML" p "沒給 --mainline、狀態檔也沒有、cwd 也沒有 → 離開碼 2,訊息給出兩條出路" "True" \ "rc==2 and 'mainline set' in out and 'system-dev/mainline.json' in out" "[\"cmd_pick\"]" echo echo "── ③ claim:一個動作三件事 ──" CL="[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"isep-hand\"]" p "★ 指派給本 token 的使用者(欄位,不是一句話)" "[['claude-code']]" \ "[pl['assignees'] for mm,p,pl in rec if mm=='PATCH' and p=='/repos/inkstone/Arcrun/issues/6']" "$CL" p "★ s/todo 換成 s/doing,p/high 留著(不是整組蓋掉)" "True" \ "[sorted(pl['labels'])==[2,4] for mm,p,pl in rec if mm=='PUT' and p.endswith('/issues/6/labels')]==[True]" "$CL" p "★ 留言第一行是【身份】isep-hand/inkstone/Arcrun/-" "True" \ "[pl['body'].split(chr(10))[0] for mm,p,pl in rec if mm=='POST' and p.endswith('/issues/6/comments')]==['【身份】isep-hand/inkstone/Arcrun/-']" "$CL" p "留言講了做完要走 handback(不推 main、不出貨)" "True" \ "any('handback' in pl['body'] and '不推 main' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" "$CL" p "三件事都做了,離開碼 0" "None" "rc" "$CL" p "--as 指定登入名時用它" "[['cloud-bot']]" \ "[pl['assignees'] for mm,p,pl in rec if mm=='PATCH']" "[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--as\",\"cloud-bot\"]" p "逾期里程碑的票(Arcrun#7)也認領得了——主線抓盡時輪到它" "None" "rc" \ "[\"cmd_claim\",\"inkstone/Arcrun#7\",\"--mainline\",\"$MAIN\"]" p "pick --claim:挑到誰就認領誰(Arcrun#6)" "True" \ "any(mm=='PATCH' and p.endswith('/issues/6') for mm,p,pl in rec)" "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--claim\",\"--name\",\"isep-hand\"]" echo echo " (該擋的:規則在 claim 這一端再驗一次,擋在**寫入之前**)" blocked() { # blocked <說明> [extra argv...] what=$1; ref=$2; shift 2 extra=$(printf '%s\n' "$@" | python3 -c "import sys,json;print(','.join(json.dumps(l.rstrip()) for l in sys.stdin if l.strip()))") argv="[\"cmd_claim\",\"$ref\",\"--mainline\",\"$MAIN\"${extra:+,$extra}]" p "$what → 離開碼 2,且沒有任何寫入" "True" \ "rc==2 and not any(mm in ('PATCH','PUT','POST') for mm,_,_ in rec)" "$argv" } blocked "掛 Human 的 ISEP#2" inkstone/ISEP#2 blocked "已指派的 ISEP#3" inkstone/ISEP#3 blocked "backlog 的 ISEP#4" inkstone/ISEP#4 blocked "沒逾期也不是主線的 Arcrun#8" inkstone/Arcrun#8 blocked "有人在做的 mira#9(s/doing 也掛著)" inkstone/mira#9 p "擋下時訊息講得出理由與出路(pick)" "True" "'Human' in out and 'pick' in out" \ "[\"cmd_claim\",\"inkstone/ISEP#2\",\"--mainline\",\"$MAIN\"]" p "身份名不在名單上 → 在打任何 API 之前就擋(一通都沒打)" "True" "rc==2 and len(rec)==0" \ "[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"路人\"]" echo echo "── ④ 動詞接得上(不帶假池子,走到網路才炸)──" out=$(python3 "$T" pick --mainline "$MAIN" 2>&1); got=$? if [ "$got" = 2 ] && printf '%s' "$out" | grep -q '拿不到 Gitea'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi printf 'want=2 got=%s pick 連不上 Gitea → 離開碼 2 並說「讀不到 ≠ 沒有」(不是離開碼 1 的假「沒票」)\n' "$got" out=$(python3 "$T" 2>&1) if printf '%s' "$out" | grep -q "ticket pick" && printf '%s' "$out" | grep -q "ticket claim"; then PASS=$((PASS+1)); printf ' ✅ ' else FAIL=$((FAIL+1)); printf ' ❌ '; fi printf '不帶參數的說明裡列得出 pick/claim(撈不到的東西等於不存在)\n' out=$(python3 "$T" usage pick 2>&1); got=$? if [ "$got" = 0 ] && printf '%s' "$out" | grep -q -- '--mainline'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi printf 'ticket usage pick 印得出用法\n' echo echo "通過 $PASS 條,失敗 $FAIL 條" [ "$FAIL" = 0 ] || exit 1