#!/usr/bin/env python3 """ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。 leo 2026-08-16 三句話,本工具就是它們的機械化: 「不要每個開新票,現有的票開在它下面的對話裡」 「我希望你把 gitea 變成可以追蹤,不是變成一個池子」 「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」 當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票** (arcrun-rag#110),而那條線早就有 hub(InkStoneCo#44)。多開一張票 = 池子加大 = 那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條,是 `new` 的**前置條件**。 四個動詞,各有一道閘: ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記 ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**) ticket new -F <檔> 開新票(要戳記+模板欄位齊全) ticket close <票> --deliverable 關票(要有交付物連結) ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作) 三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒): ticket subtask <母票> --title -F <檔> 討論串裡的一件事 → 看得見的子票+相依 ticket handoff <頂層票> --to … 同一個動作,換個名字:頂層 → 下游 repo ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作 ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼 ticket loose 撈一次:下游都關了、自己還開著的頂層票 票的寫法:`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)}): 【身份】subagent/inkstone/ISEP/feat/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""") 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: 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 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 -F <內文檔> 真的是新的一條線 → ticket new -F <內文檔> (模板要有:## 目標 / ## 驗收條件 / ## deliverable 類型) 🔴 判準不是「這件事夠不夠大」,是「**它跟現有的哪條線是同一條**」。 同一條線就進對話——多開一張票只會讓池子變大、線串不起來。 戳記已寫({STAMP_TTL // 60} 分鐘有效)。""") # ── say ────────────────────────────────────────────────────────────────── def cmd_say(argv): if len(argv) < 3 or argv[1] not in ("-F", "--file"): die("用法:ticket say -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 -F <內文檔> [--title <標題>]") repo = 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 Story(leo 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 --deliverable ") 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 """) 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: lines += ["", "**這是最後一張下游——本票的下游已經全部關閉。**", "", f"{BATON_MARK} → `{HANDBACK_TO}`", "", "**下一步**:驗一次頂層要的東西真的到齊了,到齊就 " f"`ticket close {pfull}#{pn} --deliverable `;" "沒到齊就開下一張下游票,不要把它留在原地。", "", "(本則由 `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 -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 _blocks(owner, repo, num): """反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。 Gitea 原生兩個端點是同一條邊的兩端,方向不可混淆: /issues//dependencies 這張票在等誰 → 它的下游 /issues//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 # ── subtask ────────────────────────────────────────────────────────────── def cmd_subtask(argv): """把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。 為什麼是子票而不是留言(leo 2026-08-27): 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯ 票沒完工可以察覺嗎?」 留言要有人回頭讀才看得見;票的 open/closed 是**狀態**,撈一次就在眼前。 而且相依是**硬的**,不是提醒——實測(2026-08-27,Gitea 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: die("用法:ticket subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" " [--repo <收件 repo,預設跟母票同一個>] [--label ]\n" " [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n" " [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步\n" "\n(`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)") 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 ` 是 `ticket handoff` 那個名字底下比較自然的講法,同一個東西。 # (handback 的 `--to` 是「交給誰」,subtask 沒有那個參數,不會撞。) repo = 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}") # 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到 api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies", {"owner": ORG, "repo": repo, "index": 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"`ticket 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 --to --next \"<下一步一句話>\"\n" " [--label ] [--evidence ]\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]}") print(f" → 驗過就關:ticket close {full}#{n} --deliverable ") print(f" 還缺東西:ticket subtask {full}#{n} --title \"\" -F <檔>") print() print("📌 這張表撈的是**機械事實**(相依邊的 state),不是猜文字。") print(" 走 `ticket close` 關下游時會自動回寫、這張表就不會長;") print(" 會長出來的都是走側門關掉的那些。") return loose 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} 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:])