#!/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 handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作 ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼 票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` """ import json import os import re 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""") HOST = "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 stamp_path(): return os.path.join(STAMP_DIR, ".ticket-where-ok") # ── 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"]) print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n") if not hits: print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)") for h in hits[:12]: it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])] print(f" [{h['score']}] {it['repository']['full_name']}#{it['number']} {labels}") print(f" {it['title'][:70]}") with open(stamp_path(), "w") as f: json.dump({"at": time.time(), "kws": kws, "n": len(hits), "top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"]) for h in hits[:12]]}, f) 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") # 閘二:搜到了東西,就要說明為什麼不是貼進去 if st["n"] > 0 and "--not-a-comment" not in argv: top = "\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"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管/{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 '票上回報')})") # ── 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 = "🏃 **棒子交回**" 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 [] # ── 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" " [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步") 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" " 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)") repo = opt("--repo", prepo) label = opt("--label", "s/todo") assign = opt("--assign") nxt = opt("--next") if assign and not nxt: die("🚫 指派了人卻沒寫 --next。\n" " **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n" " 那句話現在就要寫下來,不是等他重讀整串。\n" " (沒有要指派給誰就不要加 --assign,那張票會留在票池等人領。)") body = (f"Parent: {powner}/{prepo}#{pnum}\n" f"> 這張票是從母票的討論串裡長出來的一件事。\n" f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\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}(它存在、且擋著母票)") 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 CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide, "subtask": cmd_subtask, "handback": cmd_handback, "mine": cmd_mine} 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:])