Files
ISEP/scripts/ticket
T
isep-hand 19face1c41 adopt 的冪等改靠「先讀 /dependencies」:真 Gitea 對重複相依回 500 不是 409(inkstone/ISEP#133)
真 Gitea 實跑(hub=InkStoneCo#44、子票=ISEP#133):第一次 POST 成功、/blocks 回 #44、
清空快取後 has 靠 /blocks 判「在」;第二次 POST 回 500——假伺服器原本猜 409,adopt 第二次會炸。
add_dependency() 改成先讀 parent 的 /dependencies,邊在就不 POST(判準是 Gitea 的事實,不是回應碼);
假伺服器改照真的回 500;A41 56→57 條。測完 DELETE 復原(201,/blocks 空)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 10:46:43 +00:00

1603 lines
82 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env python3
"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。
leo 2026-08-16 三句話,本工具就是它們的機械化:
「不要每個開新票,現有的票開在它下面的對話裡」
「我希望你把 gitea 變成可以追蹤,不是變成一個池子」
「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票**
arcrun-rag#110),而那條線早就有 hubInkStoneCo#44)。多開一張票 = 池子加大 =
那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條,是 `new` 的**前置條件**。
四個動詞,各有一道閘:
ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記
ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**)
ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全;<repo> 寫 ISEP 或 inkstone/ISEP 都行)
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
ticket subtask <母票> --title <US> -F <檔> 討論串裡的一件事 → 看得見的子票+相依
ticket handoff <頂層票> --to <repo> … 同一個動作,換個名字:頂層 → 下游 repo
ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作
ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼
ticket loose 撈一次:下游都關了、自己還開著的頂層票
ticket usage <動詞> [--example] 用法的唯一真相源——**閘印的那一行從這裡來**
雲端工人抓票(inkstone/ISEP#131,規則機械可判,不靠 prompt 叮嚀):
ticket pick [--mainline <主線檔>] [--all] [--json] 回一張現在可以抓的票(主線抓盡才抓逾期)
ticket claim <票> [--as <登入名>] [--name <身份>] 認領=指派+s/doing+【身份】留言,一個動作
ticket pick --claim … 上面兩件連著做
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
"""
import json
import os
import re
import stat
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
# ── 身份欄:與 hooks/lib/dispatch_parse.py 共用同一份定義 ──────────────────
# leo 2026-08-27:「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
# 規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
from dispatch_parse import parse_identity, IDENTITY_ROLES
except Exception: # 拿不到共用零件就不擋(fail-open)——開票的路不該被一個 import 卡死
parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo")
def check_identity(body, what):
"""貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**,所以離線測得動。"""
if parse_identity is None:
return
ok, detail = parse_identity(body)
if ok:
return
die(f"""🚫 {what}的第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
現在的問題:{detail}
第一行照這個寫(角色三選一:{''.join(IDENTITY_ROLES)}):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標。
實害(2026-08-27):多條線並行時票上看不出誰寫的,
總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
規約全文:docs/governance/dispatch-and-reply-format.md §3""")
# TICKET_HOST 只給測試用(指到一個連不上的位址,就能在不真的開票的前提下
# 驗「閘放行了沒」——閘擋下=離開碼 2 且有 🚫;閘放行=走到網路那一層才炸)。
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開
# 模板必填欄位。糊弄的票開不出來;開出來的票就是能用的 spec。
REQUIRED_SECTIONS = ["## 目標", "## 驗收條件", "## deliverable 類型"]
VALID_KINDS = ["code", "research"]
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
# 掃這個 repo 的**所有** remote,找第一個指向本站、且帶憑證的。
# 原本寫死只認名叫 "gitea" 的 remote —— 2026-08-20 實撞:
# ISEP 這個新 repo 的 remote 叫 origin,於是這支腳本在那裡整個跑不起來,
# 「開票前先搜」那道閘在新 repo 等於不存在。閘不該綁在某個 remote 的名字上。
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host not in line:
continue
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
# 退而求其次:環境變數(雲端/CI 沒有帶憑證的 remote 時走這條)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
v = os.environ.get(env)
if v:
return v
die(f"""🔴 拿不到 {host} 的 token
這個 repo 的 remote 裡沒有一個帶憑證且指向 {host}
{out.strip() or "(沒有任何 remote"}
擇一:
• 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有)
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
_TOLERATE = () # 這一次呼叫可以吞掉的 HTTP 狀態碼(見 add_dependency
_TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None 是「回應是 null」
def api(path, payload=None, method=None):
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url, data=data, method=method or ("POST" if data else "GET"),
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try:
return json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
if e.code in _TOLERATE:
return _TOLERATED
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die(f"🔴 票的寫法是 owner/repo#N,你給的是:{s}")
return m.group(1), m.group(2), int(m.group(3))
def repo_arg(s):
"""收件 repo 的參數:`ISEP` 與 `inkstone/ISEP` 都收,回 repo 名(不含 org)。
🔴 2026-09-07 實撞(inkstone/ISEP#130):總管照票號的習慣寫 `ticket new inkstone/ISEP`
這支把它原樣接進 `/repos/inkstone/{repo}/issues` ⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`
⇒ Gitea 回 **404**,而 404 讀起來像「ISEP 這個 repo 不存在」——
於是四張票改成直接打 API 開的,**正門壞了人就走側門**,那正是 `ticket-api-bypass-guard`
在防的事。票號全稱寫 `owner/repo#N` 是本 repo 的鐵律(CLAUDE.md),
所以 `owner/repo` 這個形狀本來就該被接受,不該是 404 的來源。
org 寫成別的(`Leo/ISEP`)⇒ 當場講清楚,不要讓它變成一個 404 去猜。
"""
s = (s or "").strip()
if "/" not in s:
return s
owner, _, name = s.partition("/")
if owner != ORG:
die(f"🔴 收件 repo 的 org 是 `{ORG}`,你給的是 `{owner}`{s})。\n"
f" `Leo/` 那個 org 2026-08-14 已廢;寫 `{ORG}/{name}` 或直接寫 `{name}`。")
if not name or "/" in name or "#" in name:
die(f"🔴 收件 repo 的寫法是 `<repo>` 或 `{ORG}/<repo>`(不帶票號),你給的是:{s}")
return name
def api_soft(path):
"""讀一筆,失敗就回 None(不 die)。給「錦上添花」的資訊用,不影響任何判斷。"""
try:
url = f"{HOST}/api/v1{path}"
req = urllib.request.Request(
url, headers={"Authorization": f"token {token()}"})
return json.load(urllib.request.urlopen(req, timeout=20))
except SystemExit:
raise
except Exception:
return None
def stamp_path():
return os.path.join(STAMP_DIR, ".ticket-where-ok")
# ── 「跑過」與「看過」的分界(inkstone/ISEP#72 → comment 4873)────────────
#
# 2026-08-27 實錯(本段的來由,leo 當場點破):
# python3 scripts/ticket where 卡片 沒上雲 收檔 佇列 送達 ingest >/dev/null 2>&1
# python3 scripts/ticket new arcrun-rag -F … --title "…" → ✅ #147 已開
# 而重跑同一個搜尋、這次看輸出:**第一名就是 arcrun-rag#104**,同一件事,開了 13 天。
#
# ⇒ 舊戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。
# 而「有沒有看」在 stdout 那一端——閘本來完全碰不到。
#
# 這支函式就是把那一端變成**機械事實**:一個 fd 是不是 /dev/null
# 是 fstat 問得出來的,不是對文字的猜測(本 repo 心法第 1 條:封動作不封文字)。
#
# 🔴 刻意只認 /dev/null 這一種,不擴大到「重導到檔案」「接到 pipe」:
# 寫進檔案還讀得回來、接進 pipe 還有下游,只有 /dev/null 是**物理上找不回來**。
# 多認一種都會開始誤攔(心法第 2 條:永遠在響的警報等於沒有警報)。
def fd_is_devnull(fd):
try:
s = os.fstat(fd)
if not stat.S_ISCHR(s.st_mode):
return False # 一般檔案/pipe/socket:內容至少送到了讀得回來的地方
return s.st_rdev == os.stat(os.devnull).st_rdev
except Exception:
return True # 問不出來就當「沒被看見」——閘不准因為內部錯誤靜默放行
def excerpt(body, n=220):
t = re.sub(r"\s+", " ", body or "").strip()
return (t[:n] + "…") if len(t) > n else (t or "(沒有內文)")
def candidate_lines(detail):
"""把候選票印成同一個樣子——`where` 印它,`new` 擋下來時也印它(同一份東西)。"""
out = []
for d in detail:
out.append(f" [{d.get('score', '?')}] {d['ref']} {d.get('labels', [])}")
out.append(f" {(d.get('title') or '')[:70]}")
if d.get("excerpt"):
out.append(f" ↳ {d['excerpt'][:170]}")
return out
# ── where ────────────────────────────────────────────────────────────────
def cmd_where(argv):
if not argv:
die("用法:ticket where <關鍵字...>\n(用幾個真的會出現在票裡的詞,中英文都行)")
kws = argv
seen, hits = {}, []
for kw in kws:
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 30})
for it in api(f"/repos/issues/search?{q}") or []:
ref = it["repository"]["full_name"] + "#" + str(it["number"])
if ref in seen:
seen[ref]["score"] += 1
continue
seen[ref] = {"score": 1, "it": it}
hits = sorted(seen.values(), key=lambda x: -x["score"])
detail = []
for h in hits[:12]:
it = h["it"]
detail.append({"ref": it["repository"]["full_name"] + "#" + str(it["number"]),
"score": h["score"], "title": it["title"] or "",
"labels": [l["name"] for l in it.get("labels", [])]})
# 前 3 名補內文摘要。2026-08-27 的實害正是「光看標題看不出是同一條線」:
# arcrun-rag#104 的標題是「它把我整個 repo 的一萬多個檔案排進佇列」,
# 而它票頭第一段講的就是「這些庫都早就萃好了…直接 ingest」——同一件事。
# 拿不到就安靜跳過(純加分,不影響任何判斷)。
for d in detail[:3]:
o, r, n = parse_ref(d["ref"])
got = api_soft(f"/repos/{o}/{r}/issues/{n}")
if got:
d["excerpt"] = excerpt(got.get("body"))
print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n")
if not hits:
print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)")
for line in candidate_lines(detail):
print(line)
# 🔴 這行是本閘的全部:輸出有沒有可能到得了人眼前,是 fstat 問得出來的事實。
shown = not fd_is_devnull(1)
with open(stamp_path(), "w") as f:
json.dump({"at": time.time(), "kws": kws, "n": len(hits), "shown": shown,
"top": [d["ref"] for d in detail], "top_detail": detail}, f)
if not shown:
# stdout 被丟進 /dev/null。訊息改走 stderr(它常常還活著);
# 就算兩邊都被丟掉也沒關係——戳記已經記下 shown=false`new` 那端會擋。
print("\n⚠️ 這次搜尋的輸出被丟進 /dev/null,等於沒有人看過。"
"\n `ticket new` 會擋下來並把上面這份清單再交到你眼前一次。", file=sys.stderr)
print(f"""
── 決定要做什麼 ───────────────────────────────────────────────
命中了、而且是同一條線 → **貼進那張票的對話**(預設,也是 leo 要的)
ticket say <owner/repo#N> -F <內文檔>
真的是新的一條線 → ticket new <repo> -F <內文檔>
(模板要有:## 目標 / ## 驗收條件 / ## deliverable 類型)
🔴 判準不是「這件事夠不夠大」,是「**它跟現有的哪條線是同一條**」。
同一條線就進對話——多開一張票只會讓池子變大、線串不起來。
戳記已寫({STAMP_TTL // 60} 分鐘有效)。""")
# ── say ──────────────────────────────────────────────────────────────────
def cmd_say(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket say <owner/repo#N> -F <內文檔>")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
check_identity(body, "貼進票的留言")
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
print(f"✅ 已貼進 {owner}/{repo}#{num}")
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
print(f" {c['html_url']}")
print(f"\n📌 派工時把上面那行「定址」整串寫進【工單】,那條線才接得起來。")
# ── new ──────────────────────────────────────────────────────────────────
def cmd_new(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket new <repo 或 owner/repo> -F <內文檔> [--title <標題>]")
repo = repo_arg(argv[0])
body = open(argv[2]).read()
title = None
if "--title" in argv:
title = argv[argv.index("--title") + 1]
# 閘一:搜過了沒
try:
st = json.load(open(stamp_path()))
except Exception:
die("""🚫 開新票前要先搜「這件事該放哪」(leo 2026-08-16
leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
實錯(同日):總管沒搜就開 arcrun-rag#110,而那條線早有 hub InkStoneCo#44。
先跑: ticket where <關鍵字...>
搜完它會告訴你該 `say` 進哪張票,還是真的該 `new`。""")
if time.time() - st["at"] > STAMP_TTL:
die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where")
# 閘一之二:搜尋的結果**到得了人眼前**了沒(inkstone/ISEP#72 → comment 4873
#
# 🔴 這道閘要擋的不是「沒搜」,是「搜了但沒看」——今天實犯:
# `ticket where … >/dev/null 2>&1` 之後直接 `new`,命中的 72 張一眼都沒看,
# 而第一名 arcrun-rag#104 就是同一件事(開了 13 天)。
#
# ⚠️ 這一段刻意放在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"`
# 的人永遠不會看到候選清單——理由是閉著眼睛寫的,那道閘等於沒有。
#
# 成本落在「看」不落在「寫」:擋下來的訊息**本身就是那份被丟掉的輸出**,
# 看完重下同一個指令就會過。不要求多打任何一個字,也不判斷理由寫得好不好
# leo 2026-08-17 已證偽文字層判準:8 次誤攔、0 次正確攔截)。
if st["n"] > 0 and not st.get("shown"):
lines = candidate_lines(st.get("top_detail") or []) or \
[" " + r for r in (st.get("top") or [])]
# 只要 stderr 不是 /dev/null,這一次的擋就已經把清單交到眼前了 → 記進戳記。
# 兩邊都被丟掉時**不記**(fail-closed):下次還是擋,不會靜默放行。
if not fd_is_devnull(2):
st["shown"] = True
with open(stamp_path(), "w") as f:
json.dump(st, f)
die("""🚫 搜尋跑過了,但那份輸出沒有到任何人眼前(stdout 是 /dev/null)。
leo 2026-08-27:「**同一個 session 開兩個一樣的任務就算了,新開票沒搜尋就隨便動手開**」
實錯(同日):`ticket where … >/dev/null` → 開出 arcrun-rag#147
而命中的第一名 arcrun-rag#104 講的就是同一件事,已經開了 13 天。
⇒ **戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。**
命中的 {n} 張,前幾張在這裡——這就是剛才被丟掉的那一份:
{lines}
看完覺得真的都不是同一條線 → **重下一次一模一樣的指令就會過**,不必多打任何字。
(判準是「輸出有沒有進得了 /dev/null 以外的地方」,不看你寫了什麼理由。)""".format(
n=st["n"], lines="\n".join(lines)))
# 閘二:搜到了東西,就要說明為什麼不是貼進去
if st["n"] > 0 and "--not-a-comment" not in argv:
top = "\n".join(candidate_lines((st.get("top_detail") or [])[:8])) or \
"\n".join(" " + t for t in st["top"][:8])
die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。
命中的前幾張:
{top}
**先問一次:這件事跟上面哪一條是同一條線?**
是 → `ticket say <那張票> -F <檔>`(這是預設路徑)
不是 → 重下一次指令,帶上理由:
ticket new {repo} -F <檔> --not-a-comment "為什麼它是獨立的一條線"
理由會被寫進票的內文,往後任何人都看得到你當時怎麼判的。""")
# 閘三:模板欄位非空
missing = [s for s in REQUIRED_SECTIONS if s not in body]
if missing:
die(f"""🚫 票的模板缺欄位:{'、'.join(missing)}
**糊弄的票開不出來,開得出來的票就是能用的 spec。** 必填:
## 目標 要達成什麼(不是要改哪個檔)
## 驗收條件 做完要能證明什麼、怎麼驗
## deliverable 類型 code(→ PR)或 research(→ 貼在票上的結論)""")
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind_line = (m.group(1) if m else "").lower()
if not any(k in kind_line for k in VALID_KINDS):
die(f"🚫 `## deliverable 類型` 底下要明寫 `code` 或 `research`(現在是:{kind_line.strip() or '空的'}\n"
" 關票時會驗這個型別對應的交付物有沒有連上,所以不能含糊。")
if "--not-a-comment" in argv:
why = argv[argv.index("--not-a-comment") + 1]
body += (f"\n\n---\n> 🔎 **為什麼另開一張票而不是貼進既有的**(開票時聲明):{why}\n"
f"> 當時搜尋:`{' '.join(st['kws'])}` → 命中 {st['n']} 張。")
if not title:
die("🚫 缺 --title")
check_title(title)
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
os.remove(stamp_path()) # 戳記用掉就沒了,一次只開一張
print(f"✅ {ORG}/{repo}#{d['number']} 已開:{d['html_url']}")
# ── 標題規約閘(leo 2026-08-19:「票的寫法不受控制嗎?沒有辦法規範?」)─────────
#
# 實錯(本閘的來由):2026-08-19 一個 session 造了 17 張 `👤 裁決題:…` 與
# 1 張 `【版本】…`。兩種前綴都是 AI 自己發明的分類,都不是 User Story,
# 也都不該是票——**裁決在對話裡講,版本用里程碑**。leo:「亂搞一通」。
#
# 判準跟 empty-handed-stop-guard 同一個哲學:**封形狀,不封措辭**。
# User Story 的形狀是可枚舉的(身為…我要…我才…),自創前綴也是可枚舉的(開頭的方括號/
# 冒號式分類詞)。不做語意判斷,只認形狀。
USER_STORY_RE = re.compile(r"^\s*身為.{2,}?\s*我(要|想要).{2,}?\s*我才.{2,}")
BANNED_PREFIX_RE = re.compile(r"^\s*(?:[\U0001F300-\U0001FAFF\u2600-\u27BF]\s*)*"
r"(?:[【\[(][^】\])]{1,12}[】\])]|[^\s:]{2,10}題)\s*[:]")
def check_title(title):
if BANNED_PREFIX_RE.match(title):
die("🚫 標題不准自創分類前綴(leo 2026-08-19:「亂搞一通」)\n"
f" 你寫的:{title[:60]}\n\n"
" 2026-08-19 實錯:AI 造了『👤 裁決題:』17 張、『【版本】』1 張,\n"
" 兩種都不是 User Story,也都不該是票:\n"
" · 要 leo 裁決 → **在對話裡講**,不要開票\n"
" · 一個版本/sprint → **建里程碑**,把既有 issues 拉進去\n"
" · 真的是一條待辦 → 用 User Story 寫標題(見下)")
if not USER_STORY_RE.match(title):
die("🚫 票名一律 User Storyleo 2026-08-17;規約在 CLAUDE.md\n"
f" 你寫的:{title[:60]}\n\n"
" 格式:身為<誰>,我要<什麼>,我才<為什麼>\n"
" 例: 身為把整台電腦交給 AI 的人,我要它指得出出處,我才敢相信它讀懂了我的東西\n\n"
" 🔴 不要照抄現場的多數——2026-08-17 實查 39 張 open 票只有 6 張合規,\n"
" 照多數抄就會抄到錯的那邊。\n"
" 真的不是一條待辦?那它就不該是票(裁決→對話;版本→里程碑)。")
# ── close ────────────────────────────────────────────────────────────────
def cmd_close(argv):
if not argv:
die("用法:ticket close <owner/repo#N> --deliverable <URL>")
owner, repo, num = parse_ref(argv[0])
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
body = issue.get("body") or ""
comments = api(f"/repos/{owner}/{repo}/issues/{num}/comments") or []
blob = body + "\n" + "\n".join(c.get("body") or "" for c in comments)
deliv = None
if "--deliverable" in argv:
deliv = argv[argv.index("--deliverable") + 1]
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind = "code" if m and "code" in m.group(1).lower() else (
"research" if m and "research" in m.group(1).lower() else "unknown")
has_pr = bool(re.search(r"/pulls?/\d+", blob))
has_report = len([c for c in comments if len(c.get("body") or "") > 200]) > 0
ok = bool(deliv) or (has_pr if kind == "code" else has_report if kind == "research"
else (has_pr or has_report))
if not ok:
die(f"""🚫 這張票關不掉——找不到交付物。
票的 deliverable 類型:{kind}
票上有 PR 連結:{'有' if has_pr else '沒有'}
票上有實質回報(>200 字的 comment):{'有' if has_report else '沒有'}
**沒有交付物的票是關不掉的票**——它會一直掛在看板上刺眼,那正是設計意圖。
真的有交付物 → 先貼上去:ticket say {owner}/{repo}#{num} -F <檔>
交付物在別處 → ticket close {owner}/{repo}#{num} --deliverable <URL>""")
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"{_identity(owner, repo)}\n\n✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
# ── 完工回寫(inkstone/ISEP#92 最痛的那一格)─────────────────────────
# 關掉一張下游票之後,**同一個動作**回頭處理它的頂層票。
# 不做這件事的後果票上寫得很清楚:「頂層票會永遠掛著,而 leo 是看頂層的」。
_writeback(owner, repo, num, deliv or ("PR" if has_pr else "票上回報"))
def _writeback(owner, repo, num, deliv):
"""子票關掉 → 回頭處理每一張在等它的頂層票。
🔴 失敗要吵。回寫悄悄失敗 = 這格等於沒做,而沒有人會發現
(那正是本票在治的病)。所以任何一張處理失敗就非零離開。
"""
child = f"{owner}/{repo}#{num}"
try:
parents = _blocks(owner, repo, num)
except SystemExit:
print(f"\n⚠️ 撈不到 {child} 的頂層票(/blocks 讀失敗)——"
f"回寫這一格**沒有做**,請手動確認或跑 `ticket loose`", file=sys.stderr)
sys.exit(1)
if not parents:
print("\n📌 這張票沒有任何頂層票在等它(沒有相依邊)⇒ 不需要回寫。")
return
print(f"\n🔗 有 {len(parents)} 張頂層票在等這張票,逐一回寫:")
failed = []
for pt in parents:
pfull = pt["repository"]["full_name"]
po, pr = pfull.split("/")
pn = pt["number"]
try:
pdeps = _deps(po, pr, pn)
action, open_left = writeback_plan(pt.get("state"), pdeps)
if action == "skip":
print(f" · {pfull}#{pn} 已經是 closed ⇒ 不動")
continue
lines = [_identity(po, pr), "",
f"⬇️ **下游完工回寫**`{child}` 已關(交付物:{deliv}"]
if open_left:
lines += ["", f"這張票還在等 {len(open_left)} 張下游(都關了才輪到它):"]
lines += [f"- ◻ {i['repository']['full_name']}#{i['number']} {i['title'][:60]}"
for i in open_left]
lines += ["", "⇒ 本票維持原狀,不要當成可以收了。"]
else:
# 🔴 這一段是要**貼進 Gitea 留言**的,所以 `ticket close` 刻意寫成相對的
# ——留言會被別台機器讀到,寫死本機的絕對路徑等於寫死一個他們沒有的檔案。
# 絕對路徑那條規約(inkstone/ISEP#112)管的是**印到終端機、要人複製貼上**的字,
# 兩者分界就是這一句:「這行字會被誰複製、在哪台機器上跑?」
lines += ["", "**這是最後一張下游——本票的下游已經全部關閉。**", "",
f"{BATON_MARK} → `{HANDBACK_TO}`", "",
"**下一步**:驗一次頂層要的東西真的到齊了,到齊就 "
f"`ticket close {pfull}#{pn} --deliverable <URL>`"
"沒到齊就開下一張下游票,不要把它留在原地。", "",
"(本則由 `ticket close` 的完工回寫自動貼上,"
"機制見 `inkstone/ISEP#92`"]
api(f"/repos/{po}/{pr}/issues/{pn}/comments", {"body": "\n".join(lines)})
if action == "handback":
# 指派 + 改 tag:**欄位**才撈得到,留言撈不到。
# 08-26 那次掉棒就是話有說、欄位沒動,棒子躺在地上 14 小時。
api(f"/repos/{po}/{pr}/issues/{pn}",
{"assignees": [HANDBACK_TO]}, method="PATCH")
cur = [l["name"] for l in (pt.get("labels") or [])]
keep = [n for n in cur if not n.startswith("s/")] + ["s/review"]
_set_labels(po, pr, pn, keep)
print(f" · {pfull}#{pn} 下游全關 ⇒ 已指派 {HANDBACK_TO}s/review(等人驗)")
else:
print(f" · {pfull}#{pn} 還有 {len(open_left)} 張下游沒關 ⇒ 只記一筆")
except SystemExit:
failed.append(f"{pfull}#{pn}")
except Exception as e:
failed.append(f"{pfull}#{pn}{e}")
if failed:
print(f"\n⚠️ 這幾張頂層票**沒有回寫成功**:{'、'.join(failed)}\n"
f" 回寫失敗要吵——悄悄失敗等於這格沒做。手動補,或稍後跑 `ticket loose` 對帳。",
file=sys.stderr)
sys.exit(1)
# ── decide ───────────────────────────────────────────────────────────────
def cmd_decide(argv):
"""記錄 leo 的裁決+改狀態,**一個動作**。
leo 2026-08-16:「回覆過很多次了,**回覆過的就要記錄下來**」
「這些我答了,來自各地,**問題是你怎麼追蹤**?」
病灶:leo 從對話/手機/Gitea 各處答覆 ⇒ 總管照著做了但沒落到票上
⇒ 下一輪(或下個 session)又問一次同一題。B 題就是實例。
⇒ 所以「寫下答案」與「拿掉 Human」必須是**同一個動作**,不能只做一半。
🔄 2026-08-16 改版(leo):`s/leo` 併入 `Human`,且 **Human 與 s/* 正交**——
「要不要人批」跟「它在流程哪一格」是兩個獨立的軸。
⇒ decide **只拿掉 Human,不動 s/* 狀態**(除非呼叫端明給 --next)。
舊做法把 s/leo 換成 s/todo 會把票的真實流程位置抹掉。
"""
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket decide <owner/repo#N> -F <答案檔> [--next <狀態標籤>]\n"
"(預設只拿掉 Human、保留原本的 s/* 狀態;要同時改狀態才加 --next)\n"
"答案檔要寫「leo 原話」與「所以要做什麼」")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
check_identity(body, "裁決紀錄")
if "leo" not in body.lower() and "原話" not in body:
die("🚫 答案檔裡看不到 leo 的原話。\n"
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
" (今天 `Arcrun#132` 就是把 leo 的「確認」套到錯的提案上,同一張票誤讀兩次。)")
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
ids = {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=60")}
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
cur = [l["name"] for l in issue.get("labels") or []]
# 預設:只拿掉 Human(那是「還在等人批」的標記),流程位置維持不動
keep = [n for n in cur if n != "Human"]
if "--next" in argv:
keep = [n for n in keep if not n.startswith("s/")] + [nxt]
if nxt not in ids:
die(f"🚫 這個 repo 沒有 `{nxt}` 標籤。現有:{[n for n in ids if n.startswith('s/')]}")
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
{"labels": [ids[n] for n in keep if n in ids]}, method="PUT")
# 批完了就不該還掛在 leo 名下——指派給他的清單裡每一張都要是真的在等他
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": []}, method="PATCH")
print(f" 已拿掉 Human 並取消指派——「指派給 Leo」那份清單保持誠實。")
print(f"✅ {owner}/{repo}#{num}:答案已記進票,狀態 → {nxt}")
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
# ══════════════════════════════════════════════════════════════════════════
# 三個 Gitea 原生欄位(leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定
# 也是,**這些全部都要**」)。三者正交,各回答一個問題:
#
# 子票相依 這件事存在嗎?做完了嗎? ← 缺了它:任務藏在討論串,撈 open 票看不到
# tag s/* 它現在卡在哪一段? ← 缺了它:撈得到票但要重讀整串才知道在等什麼
# 指派 現在誰該動? ← 缺了它:大家都以為是別人的事
#
# 08-26 那次掉棒(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時)
# 三格全缺:沒有子票、tag 沒動、沒指派給任何人。
# ══════════════════════════════════════════════════════════════════════════
BATON_MARK = "🏃 **棒子交回**"
# 棒子的預設終點:總管。**這是 Gitea 的 assignee 欄位,不是一句話**——
# 撈「指派給你的」一頁就看得到,撈留言看不到。
HANDBACK_TO = os.environ.get("ISEP_HANDBACK_TO") or "claude-code"
def _labels_of(owner, repo):
return {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=100")}
def _set_labels(owner, repo, num, keep):
ids = _labels_of(owner, repo)
unknown = [n for n in keep if n not in ids]
if unknown:
die(f"🚫 這個 repo 沒有這些標籤:{unknown}\n"
f" (標籤的唯一真相源是 ISEP 的 labels.yaml,改標籤要改那裡再 sync")
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
{"labels": [ids[n] for n in keep]}, method="PUT")
def _deps(owner, repo, num):
return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
def add_dependency(parent, child):
"""讓 `child` 成為 `parent` 的相依(兩個都是 (owner, repo, num))。**冪等**
先讀 parent 的 `/dependencies`,邊已經在就不 POST、回 False——`mainline adopt`
補收同一張票兩次不該炸。
🔴 冪等**不能靠回應碼**2026-09-07 對真 Gitea 實測,重複相依回的是 **500**(不是想像中的 409
——判準改成「Gitea 現在有沒有這條邊」這個事實;409 仍容忍(別的版本可能這樣回)。
`subtask` 與 `mainline adopt`inkstone/ISEP#133)都走這一支:掛相依只有一條路,
而且走的是 `api()`——離線測試把 `api()` 換成錄音機時才錄得到它。"""
global _TOLERATE
po, pr, pn = parent
co, cr, cn = child
want = f"{co}/{cr}#{cn}"
have = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies?limit=100") or []
for d in have:
full = ((d.get("repository") or {}).get("full_name")) if isinstance(d, dict) else None
if full and f"{full}#{d.get('number')}" == want:
return False # 已經是相依
_TOLERATE = (409,)
try:
r = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies", {"owner": co, "repo": cr, "index": cn})
finally:
_TOLERATE = ()
return r is not _TOLERATED
def _blocks(owner, repo, num):
"""反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。
Gitea 原生兩個端點是同一條邊的兩端,方向不可混淆:
/issues/<N>/dependencies 這張票在等誰 → 它的下游
/issues/<N>/blocks 誰在等這張票 → 它的頂層
`subtask` POST 的是**母票的 dependencies**,所以子票這端看到的是 `blocks`。
"""
return api(f"/repos/{owner}/{repo}/issues/{num}/blocks") or []
def _identity(owner, repo):
"""票上的每一則留言第一行要表明身份(規約見 docs/governance/dispatch-and-reply-format.md §3)。
機器自動貼的留言同樣要標——不標的話,回寫留言看起來像某個人寫的,
而下一個讀票的人會去找那個人。
"""
return os.environ.get("ISEP_IDENTITY") or f"【身份】總管/{owner}/{repo}-"
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#92 — 「下游做完時頂層跟著關」的三格
#
# 票上的原話:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,
# 而 leo 是看頂層的。」
#
# 缺的三格,各由下面一段承接:
# ① 雙向連結的強制 → cmd_subtask 一個動作同時掛相依 + 在母票留下指回來的留言
# ② journey 標籤 → journey_label()(對不上就不貼,不硬湊)
# ③ 完工回寫 → writeback_plan() cmd_close 關完子票立刻回頭處理母票
# ④ 撈得出來 → is_loose() cmd_loose(③ 走側門漏掉的,這張網補回來)
#
# 🔴 這四格的判準**全部是機械事實**(相依邊在不在、state 是什麼),
# 沒有一格在猜文字。leo 2026-08-17 已證偽關鍵字黑名單那條路
# (8 次誤攔、0 次正確攔截)。
# ══════════════════════════════════════════════════════════════════════════
JOURNEY_PREFIX = "j/"
def journey_label(name):
"""`--journey 收件匣` → `j/收件匣`。只加前綴,不做語意判斷。
標籤存不存在由 `_set_labels` 去問 Gitea(不存在就擋,並指回 labels.yaml)——
**「要求某個東西在場」,不是「猜哪些名字合法」**。
⇒ 新的一條旅程要先加進 labels.yaml 再 sync,這是刻意的:
journey 是**跨票聚類的軸**,隨手造名字會讓同一條旅程長出三個拼法。
"""
n = (name or "").strip()
if not n:
die("🚫 --journey 後面要接旅程名(例:--journey 收件匣)。\n"
" 對不上任何旅程就**不要加這個參數**——票上明講不硬湊。")
return n if n.startswith(JOURNEY_PREFIX) else JOURNEY_PREFIX + n
def is_loose(state, deps):
"""這張票是不是「下游都關了、自己還開著」的頂層票。
純函式(不打網路)⇒ 判準本身測得動,不必開真的票來驗。
三個條件缺一不可:
· 自己還 open 關掉的票不用管
· 曾經有下游 沒有相依邊 ⇒ 它根本不是頂層票,不要拿它來吵
· 沒有任何 open 下游 有一張還開著就還在跑,不是掉在地上
"""
if state != "open":
return False
if not deps:
return False
return not any(d.get("state") == "open" for d in deps)
def writeback_plan(parent_state, parent_deps):
"""下游關掉之後,它的頂層票該被怎麼處理。**只回計畫,不做副作用**⇒ 離線測得動。
回 (動作, 還開著的下游):
"skip" 頂層票自己已經關了 ⇒ 沒事
"note" 還有別的下游沒關 ⇒ 只在頂層票記一筆,別假裝它可以收了
"handback" 下游全關了 ⇒ 這張票現在**只等人驗**:
指派回總管 + s/review,讓它出現在「指派給你的」
🔴 為什麼不自動關母票:關票要有交付物、要有人看過(`ticket close` 的既有閘)。
票上要的是「不能默默留著」,不是「自動消失」——
默默關掉跟默默留著是同一個病的兩面。
"""
open_left = [d for d in (parent_deps or []) if d.get("state") == "open"]
if parent_state != "open":
return "skip", open_left
return ("note" if open_left else "handback"), open_left
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#112 — 閘教的那行指令要真的跑得動
#
# 票上的原話:「**當它教的做法不存在,它就從『幫你』變成『擋你』**——
# 而且被擋的人當下正在做別的事,最可能的反應是找個理由繞過去。」
#
# 那次它為什麼跑不動,兩個獨立的原因(2026-08-31 實測,兩個都會單獨害死它):
#
# ① **相對路徑**。閘印的是 `scripts/ticket subtask …`,而相對路徑是對
# **貼上去那個人的 cwd** 解析的。總管的 cwd 是 `InkStoneCo/`,那裡的
# `scripts/ticket` 是本檔停在 2026-08-27 之前的**舊複本**
# grep -c subtask InkStoneCo/scripts/ticket → 0
# 它的 docstring 只列到「四個動詞」,於是照著貼會印出那段說明、exit 0、
# 什麼都沒發生 —— 正是票上寫的症狀。
# `hooks/lib/beacon_report.py` ② 已經會在 SessionStart 報這個舊複本,
# 但**報告不會改掉閘印出來的那一行**。)
#
# ② **用法各寫一份**。閘手寫了一段用法,`cmd_subtask` 的 die() 又寫了一段。
# 兩份已經漂了:閘那份寫 `--assign <誰做>` 卻**沒有 `--next`**
# 而 cmd_subtask 會因為「🚫 指派了人卻沒寫 --next。」直接 die(實測 exit 2
# 而且是在打任何 API 之前,所以離線也複現得出來)。
# ⇒ 就算路徑對了,那一行照樣跑不完。
#
# 🔴 修法是**拿掉可以漂的那一格**,不是把兩份對齊一次:
# · 路徑 → self_path()`__file__` 的絕對路徑。印指令的人與跑指令的人
# 是同一個檔案,沒有第二種可能。
# · 用法 → USAGE 這一份。閘去 `ticket usage subtask` 拿,不自己抄。
# · 內文模板 → body_template() 由 REQUIRED_SECTIONS 現生。
# 檢查與模板同一個來源 ⇒ 模板不可能少一段。
# ══════════════════════════════════════════════════════════════════════════
def self_path():
"""這支腳本自己的絕對路徑。**閘印出來的指令一律用它。**
不用 `sys.argv[0]`:那是「別人怎麼叫我」,被相對路徑叫進來時它就是相對的。
`__file__` 走 realpath 是「我是誰」——貼到任何 cwd 底下都指得回同一個檔案。
"""
return os.path.realpath(__file__)
# 每一段必填欄位的草稿提示。**鍵一定要是 REQUIRED_SECTIONS 裡的字串**——
# 對不上也不會漏掉那一段(下面會退成 `<填這裡>`),只是提示變笨。
SECTION_HINTS = {
"## 目標": "<這件事要達成什麼。寫目的,不要寫做法>",
"## 驗收條件": "- [ ] <怎麼算做完——別人可以自己跑一次的那種>",
"## deliverable 類型": "code",
}
def body_template():
"""子票內文的草稿,**用 REQUIRED_SECTIONS 現生**。
🔴 不要把這段字面寫死:寫死就變成第二份,而 `cmd_subtask` 檢查的是
REQUIRED_SECTIONS。有人往那個清單加一段,寫死的模板就會生出一張
**照著貼卻過不了自己那道閘**的票 —— 這張票要修的就是這個形狀。
"""
out = []
for sec in REQUIRED_SECTIONS:
out.append(sec)
out.append(SECTION_HINTS.get(sec, "<填這裡>"))
out.append("")
return "\n".join(out).rstrip() + "\n"
def subtask_example(parent=None, draft=None):
"""可以整段複製貼上的那一塊:先把內文草稿寫出來,再開子票。
刻意**不放 `--assign`**`--assign` 沒配 `--next` 會被擋(見 cmd_subtask),
而一行印出來卻跑不完的指令,正是這張票在修的病。要指派的人看 USAGE 那份,
那裡兩個參數是綁在一起寫的。
留在 <> 裡的只有三件**機器不可能知道**的事:標題、目標、驗收條件。
其餘(腳本路徑、母票、必填段落、標籤)全部已經填好。
"""
parent = parent or "<母票 owner/repo#N>"
draft = draft or "/tmp/subtask-body.md"
return (
"cat > %s <<'MD'\n" % draft
+ body_template()
+ "MD\n"
+ "%s subtask %s \\\n" % (self_path(), parent)
+ ' --title "身為<誰>,我要<什麼>,我才<為什麼>" \\\n'
+ " -F %s --label s/todo\n" % draft
)
# 🔴 `{bin}` 一定要留成佔位符、由 usage_text() 現填 self_path()
# 用法裡寫死 `ticket` 或 `scripts/ticket`,讀的人照著貼就又踩回這張票的坑。
USAGE = {
"pick":
"用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--claim [--as <登入名>] [--name <身份>]]\n"
" 回**一張**現在可以抓的票(主線抓盡才輪到逾期里程碑;不抓 backlog)。主線的成員=hub 里程碑裡的票+\n"
" hub 票+它們的相依(跨 repo)——別 repo 的同名里程碑不算主線(inkstone/ISEP#133\n"
" --mainline 不給時依序找:$ISEP_MAINLINE_FILE → ~/.claude/isep-countdown/mainline.json"
" → $CLAUDE_PROJECT_DIR/system-dev/mainline.json\n"
" 離開碼:0 = 有票(印在 stdout);1 = 真的沒有可抓的票;2 = 讀不到(讀不到 ≠ 沒有)",
"claim":
"用法:{bin} claim <owner/repo#N> [--as <登入名,預設本 token 的使用者>] [--name <身份名,預設 subagent>]"
" [--mainline <主線 json 檔>]\n"
" 認領=指派給自己 + 換成 s/doing + 第一行【身份】的留言,**一個動作**。\n"
" 抓票規則在這裡再驗一次:不是 s/todo、已有人、掛 Human、不在主線也不逾期 ⇒ 擋下(離開碼 2)",
"subtask":
"用法:{bin} subtask <母票 owner/repo#N> --title \"<User Story>\" -F <內文檔>\n"
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\n"
" [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n"
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 這兩個是一組,"
"指派了就一定要寫下一步\n"
"\n`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)",
}
def usage_text(verb):
"""用法的**唯一**一份,路徑現填成絕對路徑(inkstone/ISEP#112)。"""
return USAGE[verb].format(bin=self_path())
def cmd_usage(argv):
"""把「用法」與「可以貼著跑的那一塊」交給同一個地方產生(inkstone/ISEP#112)。
閘的訊息**呼叫這一支**,不自己抄一份 ⇒ 訊息與能跑的指令之間沒有第二份可以漂。
"""
verbs = "|".join(sorted(USAGE))
if not argv or argv[0] not in USAGE:
die("用法:ticket usage <%s> [--example] [--parent <owner/repo#N>] "
"[--draft <內文檔路徑>]\n"
" --example = 印一塊可以整段貼著跑的指令(路徑是絕對路徑,母票已填好)" % verbs)
verb = argv[0]
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
if "--example" in argv:
if verb != "subtask":
die("🚫 `ticket usage %s --example` 還沒有可貼著跑的版本。\n"
" 先看用法:ticket usage %s" % (verb, verb))
sys.stdout.write(subtask_example(opt("--parent"), opt("--draft")))
return
sys.stdout.write(usage_text(verb).rstrip() + "\n")
# ── subtask ──────────────────────────────────────────────────────────────
def cmd_subtask(argv):
"""把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。
為什麼是子票而不是留言(leo 2026-08-27):
「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯
票沒完工可以察覺嗎?」
留言要有人回頭讀才看得見;票的 open/closed 是**狀態**,撈一次就在眼前。
而且相依是**硬的**,不是提醒——實測(2026-08-27Gitea 1.26.4):
子票還開著時 PATCH 母票 state=closed → **HTTP 412
"cannot close this issue or pull request because it still has open dependencies"**
⇒ 子票沒關,母票關不掉。這是平台保證,不是自律。
刻意不要求 `ticket where` 戳記:leaf 屬於一條**已知**的線(它的母票就是那條線),
不是「新開一條線」——那道閘擋的是後者。
"""
if len(argv) < 1:
# 🔴 用法只有 USAGE 那一份(inkstone/ISEP#112)。這裡不准再抄一段:
# 抄一段就是又多一個會漂的副本,而閘印的那一行也是從 USAGE 來的。
die(usage_text("subtask"))
powner, prepo, pnum = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
title = opt("--title")
fpath = opt("-F") or opt("--file")
if not title or not fpath:
die("🚫 缺 --title 或 -F <內文檔>")
check_title(title)
body = open(fpath).read()
missing = [s for s in REQUIRED_SECTIONS if s not in body]
if missing:
die(f"🚫 子票一樣是票,模板欄位不能少:{'、'.join(missing)}\n"
" (子票通常很小,但「小」不等於「可以說不清楚」——\n"
" leo 2026-08-27:『每個事情沒有歷史記錄才是大問題』,\n"
" 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)")
# `--to <repo>` 是 `ticket handoff` 那個名字底下比較自然的講法,同一個東西。
# handback 的 `--to` 是「交給誰」,subtask 沒有那個參數,不會撞。)
repo = repo_arg(opt("--repo", opt("--to", prepo)))
label = opt("--label", "s/todo")
assign = opt("--assign")
nxt = opt("--next")
jlabel = journey_label(opt("--journey")) if "--journey" in argv else None
if assign and not nxt:
die("🚫 指派了人卻沒寫 --next。\n"
" **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n"
" 那句話現在就要寫下來,不是等他重讀整串。\n"
" (沒有要指派給誰就不要加 --assign,那張票會留在票池等人領。)")
body = (f"Parent: {powner}/{prepo}#{pnum}\n"
f"> 這張票是從母票的討論串裡長出來的一件事。\n"
f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n"
f"> **它關掉時,母票會收到一則回寫**(`ticket close` 自動做,見 `inkstone/ISEP#92`)。\n\n") + body
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
cnum = d["number"]
print(f"✅ 子票 {ORG}/{repo}#{cnum} 已開:{d['html_url']}")
_set_labels(ORG, repo, cnum, [label])
print(f" tag{label}(它卡在哪一段)")
if assign:
api(f"/repos/{ORG}/{repo}/issues/{cnum}", {"assignees": [assign]}, method="PATCH")
api(f"/repos/{ORG}/{repo}/issues/{cnum}/comments",
{"body": f"{BATON_MARK} → `{assign}`\n\n**下一步**{nxt}\n\n"
f"(這張票是 `{powner}/{prepo}#{pnum}` 的相依——它沒關,母票關不掉)"})
print(f" 指派:{assign}(誰該動)")
print(f" 下一步:{nxt}")
# 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到
add_dependency((powner, prepo, pnum), (ORG, repo, cnum))
deps = _deps(powner, prepo, pnum)
openn = [i for i in deps if i["state"] == "open"]
print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)")
# 🔴 雙向連結:相依邊只長在 Gitea 的側欄,**母票的時間軸上什麼都沒有**。
# 票上的原話:「頂層票寫了『轉去某個 repo』,但那邊的票沒有指回來,
# 從下游那張票看不出它從哪來,脈絡就斷了。」
# ⇒ 兩端各留一個看得見的指標:子票內文的 `Parent:`(上面),母票時間軸的這一則。
# 跨 repo 時尤其重要——側欄的相依很容易被當成同 repo 的東西。
plines = [_identity(powner, prepo), "",
f"➡️ **已轉往下游**`{ORG}/{repo}#{cnum}` {title}", "",
f"{d['html_url']}", "",
f"- 收件 repo`{ORG}/{repo}` tag`{label}`"
+ (f" 旅程:`{jlabel}`" if jlabel else "")
+ (f" 指派:`{assign}`" if assign else " (還沒指派,留在票池等人領)")]
if jlabel is None:
plines += ["- 旅程:**沒有貼**(開票時判定對不上任何一條,不硬湊)"]
plines += ["",
"**這張頂層票不會因為轉出去就自動關**——下游那張關掉時,"
"`ticket close` 會回頭在這裡貼一則回寫;"
"全部下游關完,這張票會被指派回總管等驗。(機制:`inkstone/ISEP#92`"]
api(f"/repos/{powner}/{prepo}/issues/{pnum}/comments", {"body": "\n".join(plines)})
print(f" 回指:已在母票 {powner}/{prepo}#{pnum} 的時間軸貼上指向子票的留言")
# journey 標籤:兩端都貼,聚類才聚得起來(只貼一端=撈不到另一端)
if jlabel:
_set_labels(ORG, repo, cnum, [label, jlabel])
pcur = [l["name"] for l in (api(f"/repos/{powner}/{prepo}/issues/{pnum}")
.get("labels") or [])]
if jlabel not in pcur:
_set_labels(powner, prepo, pnum, pcur + [jlabel])
print(f" 旅程:`{jlabel}`(母子兩端都貼——只貼一端就聚不起來)")
else:
print(" 旅程:沒有貼(沒給 --journey)。對得上就補一次:"
f"`{self_path()} subtask … --journey <旅程名>`;對不上就不要硬湊。")
print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:")
for i in deps:
mark = "◻" if i["state"] == "open" else "☑"
print(f" {mark} {i['repository']['full_name']}#{i['number']} {i['title'][:50]}")
print(" 在它們全關之前,母票 PATCH state=closed 會被 Gitea 擋下(412)。")
# ── handback ─────────────────────────────────────────────────────────────
def cmd_handback(argv):
"""收工=把棒子交回去:**指派 + tag + 下一步**,一個動作三件事。
leo 2026-08-27:「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」
🔴 關鍵在「指派」是**欄位**不是文字——撈一次就看得到,不必讀 comment。
08-26 那次掉棒,交回的話寫在 comment 裡,票沒指派、tag 沒動 ⇒ 棒子躺在地上 14 小時。
"""
if len(argv) < 1:
die("用法:ticket handback <owner/repo#N> --to <claude-code|Leo> --next \"<下一步一句話>\"\n"
" [--label <s/xxx>] [--evidence <URL 或一行實測>]\n"
"--to claude-code 交回總管;--to Leo = 要 leo 親手做,會自動加 Human")
owner, repo, num = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
to = opt("--to")
nxt = opt("--next")
if not to or not nxt:
die("🚫 缺 --to 或 --next。\n"
" **「下一步」不准省略**——收下棒子的人要能不重讀整串就知道該做什麼。\n"
" (這正是 08-26 掉棒那次缺的:話有說,但沒有欄位在說「這張票在等你」)")
label = opt("--label")
ev = opt("--evidence")
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
cur = [l["name"] for l in issue.get("labels") or []]
deps = _deps(owner, repo, num)
openn = [i for i in deps if i["state"] == "open"]
lines = [f"{BATON_MARK} → `{to}`", "",
f"**下一步**{nxt}"]
if ev:
lines += ["", f"**證據**{ev}"]
if openn:
lines += ["", "**未關的相依**(它們全關之前這張票關不掉):"]
lines += [f"- {i['repository']['full_name']}#{i['number']} {i['title'][:60]}" for i in openn]
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": "\n".join(lines)})
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": [to]}, method="PATCH")
keep = list(cur)
if label:
keep = [n for n in keep if not n.startswith("s/")] + [label]
if to == "Leo" and "Human" not in keep:
keep.append("Human")
if to != "Leo" and "Human" in keep:
keep = [n for n in keep if n != "Human"]
_set_labels(owner, repo, num, keep)
print(f"✅ {owner}/{repo}#{num} 棒子交回 `{to}`")
print(f" 指派:{to}  tag{[n for n in keep if n.startswith('s/')] or '(未動)'}"
f"{' Human' if 'Human' in keep else ''}")
print(f" 下一步:{nxt}")
if openn:
print(f" ⚠️ 還有 {len(openn)} 張未關的相依 ⇒ 這張票現在關不掉(412),這是對的")
print("\n📌 三件事是同一個動作——不會只留言而忘了指派,也不會指派了而看板還說 s/doing。")
# ── mine ─────────────────────────────────────────────────────────────────
def cmd_mine(argv):
"""撈一次:**哪幾根棒子在誰手上、每根的下一步是什麼**。
不是輪詢——掛在本來就會做的動作上(session 開場、收工對帳)跑一次。
"""
user = argv[argv.index("--user") + 1] if "--user" in argv else None
if user in (None, "", "me"):
who = "總管(本 token 的身份)"
items = api("/repos/issues/search?state=open&type=issues&assigned=true&limit=100") or []
else:
who = user
items = []
for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
q = urllib.parse.urlencode({"state": "open", "assigned_by": user, "limit": 50})
for it in api(f"/repos/{ORG}/{r['name']}/issues?{q}") or []:
if it.get("pull_request"):
continue
it.setdefault("repository", {"full_name": f"{ORG}/{r['name']}"})
items.append(it)
print(f"🏃 在「{who}」手上的棒子:{len(items)}\n")
if not items:
print(" (沒有——每一根都已經交出去或關掉了)")
for it in items:
ref = it["repository"]["full_name"] + "#" + str(it["number"])
st = [l["name"] for l in it.get("labels") or []]
o, rp, n = ref.split("/")[0], ref.split("/")[1].split("#")[0], it["number"]
deps = _deps(o, rp, n)
openn = [i for i in deps if i["state"] == "open"]
print(f" ● {ref} {st}")
print(f" {it['title'][:64]}")
nxt = _last_next(o, rp, n)
print(f" 下一步:{nxt or '❌ 沒有人寫下一步(交棒時漏了 --next)'}")
if openn:
print(f" ◻ 未關相依 {len(openn)}" +
"、".join(f"{i['repository']['full_name']}#{i['number']}" for i in openn))
print("\n📌 終點是 leo 說的「直到最後交出正確 deliverable 並且驗證」——"
"驗完就關票、清指派,這張表才會縮短。")
def _last_next(owner, repo, num):
"""從最後一則交棒留言抓「下一步」。抓不到就誠實回 None,不要編。"""
try:
cs = api(f"/repos/{owner}/{repo}/issues/{num}/comments?limit=100") or []
except SystemExit:
return None
for c in reversed(cs):
b = c.get("body") or ""
if BATON_MARK.strip("*") .replace("**", "") in b or BATON_MARK in b:
m = re.search(r"\*\*下一步\*\*[:]\s*(.+)", b)
if m:
return m.group(1).strip()[:100]
return None
# ── loose ────────────────────────────────────────────────────────────────
def cmd_loose(argv):
"""撈一次:**下游都關了、自己還開著**的頂層票。
票上的驗收條件第 3、4 條(`inkstone/ISEP#92`):
「撈一次『已經沒有 open 下游票、但自己還開著的頂層票』→ 要撈得出來(現在撈不出來)」
「拿現有的頂層票跑一次 → 要能指出哪幾張是這種情況」
這是 `ticket close` 自動回寫的**補網**:回寫只在走正門(`ticket close`)時發生,
而關票的側門(直接 PATCH `state=closed`)是刻意放行的
(見 `hooks/ticket-api-bypass-guard.sh`:「帶了 ID 的子路徑」一律放行)。
⇒ 側門漏掉的,由這張網撈回來。**不是輪詢**——掛在開場/收工對帳跑一次。
"""
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
org = opt("--org", ORG)
only = opt("--repo")
print(f"🔎 掃 `{org}` 的 open 票,找「下游都關了、自己還開著」的頂層票"
+ (f"(只看 {only}" if only else "") + " …")
items, page = [], 1
while page <= 12:
q = urllib.parse.urlencode({"state": "open", "type": "issues",
"limit": 50, "page": page, "owner": org})
got = api(f"/repos/issues/search?{q}") or []
if not got:
break
items += got
page += 1
if only:
items = [i for i in items if i["repository"]["full_name"].split("/")[-1] == only]
print(f" open 票 {len(items)} 張,逐張問它的下游 …")
def probe(it):
full = it["repository"]["full_name"]
o, r = full.split("/")
try:
deps = _deps(o, r, it["number"])
except SystemExit:
return None # 讀不到 ≠ 沒有下游;寧可漏報也不要編一筆出來
except Exception:
return None
return (full, it, deps)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=12) as ex:
probed = [x for x in ex.map(probe, items) if x]
parents = [x for x in probed if x[2]]
loose = [x for x in parents if is_loose("open", x[2])]
print(f"\n 有下游相依的頂層票:{len(parents)} 張")
print(f" 其中「下游都關了、自己還開著」:**{len(loose)} 張**\n")
if not loose:
print("✅ 一張都沒有——每張頂層票都還有下游在跑,或已經跟著關了。")
for full, it, deps in loose:
n = it["number"]
st = [l["name"] for l in it.get("labels") or []]
who = [a["login"] for a in (it.get("assignees") or [])] or ["(沒有人)"]
print(f" ● {full}#{n} {st} 指派:{'、'.join(who)}")
print(f" {it['title'][:64]}")
for d in deps:
print(f" ☑ {d['repository']['full_name']}#{d['number']}"
f" {d['title'][:52]}")
# 絕對路徑(inkstone/ISEP#112):相對的 `scripts/ticket` 會被貼到別的 cwd 底下,
# 在那裡叫到的可能是本檔的舊複本(`InkStoneCo/scripts/ticket` 就是)。
print(f" → 驗過就關:{self_path()} close {full}#{n} --deliverable <URL>")
print(f" 還缺東西:{self_path()} subtask {full}#{n} --title \"<US>\" -F <檔>")
print()
print("📌 這張表撈的是**機械事實**(相依邊的 state),不是猜文字。")
print(" 走 `ticket close` 關下游時會自動回寫、這張表就不會長;")
print(" 會長出來的都是走側門關掉的那些。")
return loose
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#131 — 雲端工人抓票:規則是機械可判的,不靠 prompt 叮嚀
#
# 票上的原話:Routine 08-06 起讀 `journeys.md` 本 sprint 段,08-19 到期沒續,
# **連續兩週讀到殘骸只能空轉**。09-07 把 `cloud-worker.md` 步驟 1 改成從 Gitea 主線
# 里程碑抓票(inkstone/InkStoneCo#118)——但那是一段給人讀的字,讀漏一句就抓錯。
# 這一段把它做成一個指令,規則寫在 `pickable()` 一支純函式裡,離線測得動。
#
# 規則(全部是 Gitea 欄位,沒有一格在猜文字):
# 可抓 s/* 狀態恰好是 {s/todo} 沒有 assignee 沒有 Humanhuman/* 不是 hub 有里程碑
# 且里程碑 = 主線;主線抓盡才抓「逾期的 open 里程碑」;backlog/沒里程碑一律不抓
# 認領 指派 + s/doing +【身份】留言,一個動作(`claim`)
# 完成 分支 證據 s/review `handback`——那半本來就有,不重做
#
# 🔴 為什麼是「s/* 恰好等於 {s/todo}」而不是「有 s/todo」:2026-09-07 實查
# `inkstone/mira#6` 同時掛著 s/doing 與 s/todoexclusive 只擋 UI,不擋 API)。
# 只看「有 s/todo」會把一張別人正在做的票抓走。要求的是**狀態在場且唯一**,
# 仍然是「要求某個東西在場」那個形狀,不是黑名單。
#
# 🔴 Human 那一格用的是**標籤**(唯一識別碼),不是票上有沒有 👤 這個字——
# cloud-worker.md 寫過「票上沒有 👤」,那是文字判準,leo 2026-08-17 已證偽那條路。
# `human/exec`(執行者是人)與 `Human` 同組(labels.yaml「人的介入」),一起算。
# ══════════════════════════════════════════════════════════════════════════
_ML = None
def _mainline_lib():
"""`hooks/lib/mainline.py`——「主線」這件事的唯一存放處。norm() 也只有那一份。"""
global _ML
if _ML is None:
import importlib.machinery
import importlib.util
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib", "mainline.py")
loader = importlib.machinery.SourceFileLoader("isep_mainline_lib", path)
spec = importlib.util.spec_from_file_location("isep_mainline_lib", path, loader=loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
_ML = mod
return _ML
def _now():
"""現在。測試用 ISEP_COUNTDOWN_NOWepoch 秒)把時鐘定住——跟 countdown 同一個開關。"""
from datetime import datetime, timezone
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
if override:
try:
return datetime.fromtimestamp(float(override), tz=timezone.utc)
except Exception:
pass
return datetime.now(timezone.utc)
def _label_names(issue):
out = []
for l in issue.get("labels") or []:
out.append(l["name"] if isinstance(l, dict) else str(l))
return out
def human_gated(labels):
"""掛著「人的介入」那一組標籤(labels.yaml`Human``human/*`)。"""
return any(n == "Human" or n.startswith("human/") for n in labels)
def pickable(issue, milestone_title):
"""這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。**
`milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。
理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。
"""
if issue.get("pull_request"):
return False, "這是 PR,不是票"
labels = _label_names(issue)
st = sorted(n for n in labels if n.startswith("s/"))
if st != ["s/todo"]:
return False, "狀態不是單一的 s/todo(現在是 %s" % (st or "沒有 s/*")
if issue.get("assignees") or issue.get("assignee"):
who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \
or [(issue.get("assignee") or {}).get("login")]
return False, "已經指派給 %s" % "、".join(str(w) for w in who if w)
if human_gated(labels):
return False, "掛著 %s——要 leo 親手做的" % "、".join(n for n in labels if n == "Human" or n.startswith("human/"))
if "hub" in labels:
# labels.yamlhub =「聚合一批 leaf 的 scope 容器,本身不掛 milestone、不對應 PR」
# 2026-09-07 實查:ISEP#3135 五張 hub 掛在逾期里程碑上、s/todo、沒人——
# 照欄位規則會被抓走,但它們不是一件可以做完的事。容器不是任務。
return False, "是 hub(scope 容器,不是一件可以做完的事)"
if milestone_title is None:
# inkstone/ISEP#133:呼叫端已經用相依邊確認它在主線上(別 repo 的票靠相依進來,
# 本來就不掛 hub 里程碑)⇒ 這一格不看里程碑。
return True, "ok"
ms = (issue.get("milestone") or {}).get("title")
if not ms:
return False, "沒有里程碑(backlog/沒排進 sprint 的不抓)"
norm = _mainline_lib().norm
if norm(ms) != norm(milestone_title):
return False, "里程碑是「%s」,不是「%s」" % (ms, milestone_title)
return True, "ok"
def pick_order(issue):
"""同一個里程碑裡先抓誰:p/high → 沒標 → p/low;再看開票時間(舊的先);最後票號。
結果要**可重現**——同一個池子問兩次要拿到同一張。"""
labels = _label_names(issue)
prio = 0 if "p/high" in labels else (2 if "p/low" in labels else 1)
return (prio, issue.get("created_at") or "", issue.get("number") or 0)
def _ref_of(issue, owner=None, repo=None):
full = ((issue.get("repository") or {}).get("full_name")) or ("%s/%s" % (owner, repo))
return "%s#%s" % (full, issue.get("number"))
def load_mainline(argv):
"""主線從哪來(依序):--mainline <檔> → $ISEP_MAINLINE_FILE → 本機狀態檔
`scripts/mainline set` 寫的那個)→ $CLAUDE_PROJECT_DIR/system-dev/mainline.json(進 repo 的那份,
雲端 clone 就讀得到,inkstone/InkStoneCo#118)。回 (dict, 來源說明)。"""
def opt(name):
return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else None
ML = _mainline_lib()
tried = []
explicit = opt("--mainline") or os.environ.get("ISEP_MAINLINE_FILE", "").strip()
if explicit:
try:
with open(explicit) as f:
d = json.load(f)
except Exception as e:
die("🔴 讀不到主線檔 %s%s\n 讀不到 ≠ 沒有主線——先把檔案路徑修對,不要因此去抓別的票。" % (explicit, e))
if not isinstance(d, dict) or not d.get("title"):
die("🔴 主線檔 %s 裡沒有 title 欄——那不是 `scripts/mainline set` 寫出來的檔。" % explicit)
return d, explicit
tried.append(ML.path())
d = ML.load()
if d:
return d, ML.path()
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
cand = os.path.join(root, "system-dev", "mainline.json")
tried.append(cand)
try:
with open(cand) as f:
d = json.load(f)
if isinstance(d, dict) and d.get("title"):
return d, cand
except Exception:
pass
die("""🔴 現在沒有主線 ⇒ 沒有「可以抓的票」這回事。
找過:
%s
這不是叫你去抓 backlog 或沒里程碑的票——那是總管排 sprint 的事,不是工人的。
出路(擇一):
• 雲端:clone inkstone/InkStoneCo,用它 repo 裡那份 → `%s pick --mainline <clone>/system-dev/mainline.json`
• 本機:`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`""" % (
"\n".join(" · " + t for t in tried), self_path()))
def _repos(owner):
return [r["name"] for r in (api("/orgs/%s/repos?limit=100" % owner) or [])
if isinstance(r, dict) and r.get("name")]
def _open_milestones(owner, repos):
"""每個 repo 的 open milestone。回 [(repo, milestone dict)]。"""
out = []
for r in repos:
for m in (api("/repos/%s/%s/milestones?state=open&limit=100" % (owner, r)) or []):
if isinstance(m, dict) and m.get("title"):
out.append((r, m))
return out
def _issues_in(owner, repo, title):
q = urllib.parse.urlencode({"state": "open", "type": "issues", "milestones": title, "limit": 100})
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []
for it in rows:
if isinstance(it, dict):
it.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
return [it for it in rows if isinstance(it, dict)]
def _fetch_issue(ref):
ML = _mainline_lib()
parts = ML.split_ref(ref)
if not parts:
return None
it = api("/repos/%s/%s/issues/%d" % parts)
if not isinstance(it, dict) or not it.get("number"):
return None
it.setdefault("repository", {"full_name": "%s/%s" % parts[:2]})
return it
def mainline_members(ms):
"""主線的成員(inkstone/ISEP#133):hub 里程碑裡的票+hub 票+它們的相依(跨 repo)。
判準在 `hooks/lib/mainline.py::collect_members`,這裡只是把本檔的 `api` 遞進去。"""
return _mainline_lib().collect_members(api, ms)
def pick_candidates(ms, now=None):
"""依序回 [(組名, 里程碑標題, [可抓的票…])]:第一組是主線,之後是逾期的 open 里程碑
(最逾期的先)。**每一組裡都已經照 pick_order 排好。**
主線那一組的成員由相依邊決定(`mainline_members`),**不是**同名里程碑:
別 repo 裡叫同一個名字的里程碑,裡面的票不會被當成主線抓走(inkstone/ISEP#133)。
逾期那幾組是**一個里程碑物件一組**(owner/repo#id),同名不合併。"""
ML = _mainline_lib()
now = now or _now()
owner = ms.get("owner") or ORG
anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id"))
refs, info = mainline_members(ms)
found = []
for ref in refs:
it = _fetch_issue(ref)
if not it or (it.get("state") or "open") != "open":
continue
ok, _ = pickable(it, None)
if ok:
found.append(it)
groups = [("主線", ms["title"], sorted(found, key=pick_order))]
repos = _repos(owner)
overdue = []
for r, m in _open_milestones(owner, repos):
if "%s/%s#%s" % (owner, r, m.get("id")) == anchor:
continue
due = ML.due_of(m)
if due and due < now:
overdue.append((due, r, m))
for due, r, m in sorted(overdue, key=lambda x: x[0]):
found = []
for it in _issues_in(owner, r, m.get("title")):
if str((it.get("milestone") or {}).get("id")) != str(m.get("id")):
continue
ok, _ = pickable(it, m.get("title"))
if ok:
found.append(it)
groups.append(("逾期里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")),
sorted(found, key=pick_order)))
return groups
def cmd_pick(argv):
"""回一張現在可以抓的票。離開碼:0 有、1 真的沒有、2 讀不到/沒主線。"""
if argv and argv[0] in ("-h", "--help"):
die(usage_text("pick"), 0)
ms, src = load_mainline(argv)
as_json = "--json" in argv
show_all = "--all" in argv
try:
groups = pick_candidates(ms)
except (urllib.error.URLError, OSError) as e:
die("🔴 拿不到 Gitea%s)。讀不到 ≠ 沒有可抓的票——先修連線,不要空轉也不要抓別的。" % e)
flat = [(g, t, it) for g, t, its in groups for it in its]
if as_json:
rows = [{"ref": _ref_of(it), "title": it.get("title"), "group": g, "milestone": t,
"labels": _label_names(it), "created_at": it.get("created_at")}
for g, t, it in flat]
print(json.dumps(rows if show_all else (rows[0] if rows else None), ensure_ascii=False, indent=2))
sys.exit(0 if rows else 1)
print("🎯 主線:「%s」(來源:%s" % (ms["title"], src))
for g, t, its in groups:
if g == "主線" or its or show_all:
print(" %s%s」:可抓 %d 張" % (g, t, len(its)))
print()
if not flat:
print("⚪ 沒有可以抓的票——主線與逾期里程碑都抓盡了。")
print(" 出路:收工回報(這是正常結果,不是故障)。")
print(" 🔴 不要去抓 backlog/沒里程碑/掛 Human/已有人的票——那些不在規則裡,抓了就是跳線。")
sys.exit(1)
shown = flat if show_all else flat[:1]
for g, t, it in shown:
print(" ● %s %s" % (_ref_of(it), _label_names(it)))
print(" %s" % (it.get("title") or "")[:80])
print(" %s%s」 開票 %s" % (g, t, (it.get("created_at") or "")[:10]))
print()
if "--claim" in argv:
top = flat[0][2]
cmd_claim([_ref_of(top)] + [a for a in argv if a != "--claim"])
return
print("📌 認領(指派+s/doing+【身份】留言,一個動作):")
print(" %s claim %s --name <你的名字>" % (self_path(), _ref_of(flat[0][2])))
print(" 做完:分支+證據 → `%s handback %s --to %s --label s/review --next \"\" --evidence <URL>`"
% (self_path(), _ref_of(flat[0][2]), HANDBACK_TO))
def cmd_claim(argv):
"""認領=指派 s/doing +【身份】留言,**一個動作**。抓票規則在這裡再驗一次。"""
if not argv or argv[0] in ("-h", "--help"):
die(usage_text("claim"), 0 if argv else 2)
owner, repo, num = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default
name = opt("--name") or "subagent"
ident = "【身份】%s%s/%s%s" % (name, owner, repo, opt("--branch") or "-")
# 身份先驗,**在打任何 API 之前**——名單以外的名字連 Gitea 都不會碰到
check_identity(ident, "認領留言")
ms, _ = load_mainline(argv)
ML = _mainline_lib()
who = opt("--as") or (api("/user") or {}).get("login")
if not who:
die("🔴 拿不到本 token 的使用者名稱,也沒給 --as。")
issue = api("/repos/%s/%s/issues/%d" % (owner, repo, num))
issue.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
ims = issue.get("milestone") or {}
ref = "%s/%s#%d" % (owner, repo, num)
# 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題
on_mainline = ref in set(mainline_members(ms)[0])
ok, why = pickable(issue, None if on_mainline else (ims.get("title") or ""))
if ok and not on_mainline:
due = ML.due_of(ims)
if not (due and due < _now()):
ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期(別 repo 的同名里程碑不算主線)" % (
ims.get("title"), ms["title"])
if not ok:
die("""🚫 %s/%s#%d 現在不能抓:%s
抓票規則(inkstone/ISEP#131,全部是 Gitea 欄位):
s/* 恰好是 s/todo 沒有 assignee 沒有 Humanhuman/* + 在主線上(hub 里程碑或相依;主線抓盡才抓逾期)
出路:`%s pick` 會回一張符合規則的票;這張如果真的該你做,回票上問總管改欄位,不要硬抓。""" % (
owner, repo, num, why, self_path()))
api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"assignees": [who]}, method="PATCH")
keep = [n for n in _label_names(issue) if not n.startswith("s/")] + ["s/doing"]
_set_labels(owner, repo, num, keep)
lines = [ident, "",
"🙋 **認領** → `%s`" % who, "",
"- 里程碑:「%s%s" % (ims.get("title") or "(無,靠相依在主線上)", "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)"),
"- 狀態:s/todo → s/doing",
"- 做完:分支+證據 → `s/review` `ticket handback`(不推 main、不出貨、不改里程碑)"]
api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)})
print("✅ 認領 %s/%s#%d → `%s`" % (owner, repo, num, who))
print(" 指派:%s  tag%s" % (who, [n for n in keep if n.startswith("s/")]))
print(" 留言第一行:%s" % ident)
print("\n📌 做完:%s handback %s/%s#%d --to %s --label s/review --next \"\" --evidence <URL>"
% (self_path(), owner, repo, num, HANDBACK_TO))
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close,
"decide": cmd_decide, "subtask": cmd_subtask, "handoff": cmd_subtask,
"handback": cmd_handback, "mine": cmd_mine, "loose": cmd_loose,
"usage": cmd_usage, "pick": cmd_pick, "claim": cmd_claim}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(0 if len(sys.argv) < 2 else 2)
CMDS[sys.argv[1]](sys.argv[2:])