雲端工人抓票規則做成 ticket pick/claim,規則本體是一支純函式(inkstone/ISEP#131)

- scripts/ticket 多兩個動詞:pick(給定主線檔回一張可抓的票,主線抓盡才抓逾期里程碑,
  不抓 backlog/沒里程碑)與 claim(指派+s/doing+【身份】留言,一個動作;規則在寫入前再驗一次)
- 規則全是 Gitea 欄位:s/* 恰好是 {s/todo}+沒 assignee+沒 Human/human/*+不是 hub+里程碑=主線
  · 「恰好」而非「有 s/todo」:09-07 實查 mira#6 同時掛 s/doing+s/todo
  · hub 排除:09-07 實查 ISEP#31–35 五張 hub 掛在逾期里程碑、s/todo、沒人,但容器不是任務
- 離開碼分三種:0 有票/1 真的沒有/2 讀不到或沒主線(讀不到 ≠ 沒有)
- scripts/test-ticket-pick.sh 48 條全離線;README/plugin.json 腳本數 49→50 在本樹實數
- 版本號待總管定版(scripts/ticket 會被 plugin 載入)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTZ9QtvjY7MNxjfQbexAm7
This commit is contained in:
Claude
2026-09-07 01:05:30 +00:00
parent 605f1fe5a6
commit 354480901a
5 changed files with 603 additions and 3 deletions
+224
View File
@@ -0,0 +1,224 @@
#!/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 到期)/未來 M2026-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 <python 片段> —— 把 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 <說明> <want> <python 片段> [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/todos/doing 同時掛著(mira#6 的形狀)→ 不可抓,有人在做" "False" "m.pickable(I('mira',9,['s/todo','s/doing'],'main'),'主線 M')[0]"
p "hubscope 容器,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#6p/high" "True" "'inkstone/Arcrun#6' in out and rc is None" "$ARGS"
p "只回一張(不是整份清單)" "1" "out.count(' ● ')" "$ARGS"
p "★ --all:主線裡可抓的恰好是 Arcrun#6、ISEP#1 兩張——Humanassigneebacklog 三張都不在" \
"['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 "backlogISEP#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 只讀不寫(沒有 POSTPATCHPUT" "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/doingp/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-handinkstone/Arcrun-" "True" \
"[pl['body'].split(chr(10))[0] for mm,p,pl in rec if mm=='POST' and p.endswith('/issues/6/comments')]==['【身份】isep-handinkstone/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 <說明> <ref> [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#9s/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