Files
ISEP/scripts/test-ticket-pick.sh
T
isep-hand d4bf172990 主線成員改由相依邊決定,不靠同名里程碑;adopt 別 repo 的票=加相依到 hub 票;帳本落在 InkStoneCo/system-dev(inkstone/ISEP#133 → 6743)
leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個
milestone,不同 repo 用指針」。上一版 scripts/mainline 檔頭把現場的樣子(5 個同名)寫成
「既有做法」,成員也照標題跨 repo 收;總管照它做、抄進 ops-facts、還在 arcrun-rag 補建了一個同名的。

改了什麼(判準全部是 Gitea 欄位,沒有標題比對):
- hooks/lib/mainline.py:collect_members()=hub 里程碑裡的票+hub 票,沿 /dependencies 邊
  (跨 repo)收下去;belongs() 要同時拿到「掛哪個 milestone」+「誰把它當相依」才准說「不屬於」,
  只知一半回 None(閘放行);project_roots() 給主線檔與帳本共用
- scripts/mainline:set --hub <票>、adopt 別 repo ⇒ POST 相依到 hub 票(--via 可指定成員),
  沒 hub 票時擋下並給兩條走得通的路,不建同名里程碑;has 查 /blocks;
  refresh --members 才重收(真 Gitea 25 張要 19 秒,SessionStart 成本不變)
- scripts/ticket:add_dependency() 一份(subtask 與 adopt 共用,409 冪等);pick 主線組走成員清單,
  逾期組一個里程碑物件一組;claim 的主線判定改看相依;pickable(None) 給靠邊進來的票
- hooks/mainline-focus-guard.sh:新鮮查詢問 milestone+/blocks,補收出路明講不要開同名里程碑
- scripts/milestone-account:帳本找家用同一把尺,退回原始碼目錄只在它有 .git(plugin 快取沒有)

測試:新 hooks/tests/mainline-members-by-dependency.test.sh 56 條(假 Gitea 照真的回 201/409);
scripts/test-ticket-pick.sh 48→53 條(池子改用相依邊,補「同名沒邊 ⇒ 不抓」);
既有 mainline-focus-guard/repo-mirror/gitea-fallback/idle/milestone-account/debt-worklist/
handoff-writeback 全綠。真 Gitea 唯讀實跑:set --hub inkstone/InkStoneCo#44 收到 25 張、5 個 repo,
mira#6/ISEP#130/ISEP#140/arcrun-rag#104 都在,不必新增任何邊。

版本:待總管定版(改了會被載入的 hook/scripts)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 10:40:55 +00:00

250 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"], None), # ★ 可抓:沒里程碑,靠相依邊在主線上
I("ISEP", 13, ["s/todo"], "main"), # ISEP 裡「同名」的里程碑:不是主線(ISEP#133)
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"]}
# 相依邊(inkstone/ISEP#133):主線錨在 Arcrun#48Arcrun#6 把 ISEP#1 當相依 ⇒ ISEP#1 在主線上;
# ISEP#13 掛在 ISEP 的同名里程碑但沒有任何邊 ⇒ 不在主線上。mira#9 同理(同名、沒邊)。
DEPS = {"Arcrun#6": ["ISEP#1"]}
BLOCKS = {}
for _p, _cs in DEPS.items():
for _c in _cs:
BLOCKS.setdefault(_c, []).append(_p)
def _by_ref(ref):
repo, n = ref.split("#")
return next(i for i in POOL[repo] if i["number"] == int(n))
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+)/(dependencies|blocks)(\?.*)?$", path)
if m:
table = DEPS if m.group(3) == "dependencies" else BLOCKS
return [_by_ref(r) for r in table.get(f"{m.group(1)}#{m.group(2)}", [])]
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 "★ ISEP#1 沒掛里程碑,是靠「Arcrun#6 把它當相依」進主線的(成員=相依邊,ISEP#133)" "True" \
"'inkstone/ISEP#1' in out and any(p.endswith('/issues/6/dependencies?limit=100') for _,p,_ in rec)" "$ALL"
p "★ ISEP#13 掛在 ISEP 裡**同名**的里程碑、沒有任何邊 → 不在主線裡(同名不算,leo 09-07)" "False" \
"'inkstone/ISEP#13' in out" "$ALL"
p "主線那一組只讀 hub 里程碑那一個 repo 的 milestone 清單(不去別的 repo 找同名)" "True" \
"not any('/repos/inkstone/ISEP/issues?' in p and '主線' in p for _,p,_ in rec)" "$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
blocked "掛在 ISEP 同名里程碑、沒有相依邊的 ISEP#13(同名不是主線)" inkstone/ISEP#13
p "claim 靠相依進主線的 ISEP#1(沒里程碑)→ 過(留言寫「靠相依在主線上」)" "True" \
"rc is None and any('靠相依' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" \
"[\"cmd_claim\",\"inkstone/ISEP#1\",\"--mainline\",\"$MAIN\"]"
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