Files
ISEP/scripts/ticket
T
Leo 9d20ef925e subtask 指派了人就一定要寫下一步——不然新子票一開出來就是「棒子沒有下一步」
實跑 ticket mine 時自己抓到:剛用 subtask 開的 #143 顯示
「 沒有人寫下一步」——工具自己造出了它要消滅的那個狀態。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:20 +08:00

604 lines
31 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 <檔> 開新票(要戳記+模板欄位齊全)
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
ticket subtask <母票> --title <US> -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
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 <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()
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> -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 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"✅ 結案。交付物:{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 <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
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-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:
die("用法:ticket subtask <母票 owner/repo#N> --title \"<User Story>\" -F <內文檔>\n"
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\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 <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
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:])