merge: 棒子不會掉——子票相依+tag+指派三格全用 Gitea 原生欄位(inkstone/ISEP#58)

衝突只有 docs/hooks-inventory.md 的 A 組表格,解法照 ISEP#59 comment 4763 指定的
「兩列都留」:保留 main 上 #73 更新過的 ticket-api-bypass 敘述與 reply-identity-guard,
再加上本分支新增的 comment-carries-task-guard。標頭數字留到 #62 併完後一次實數。
This commit is contained in:
2026-08-27 17:56:11 +08:00
9 changed files with 816 additions and 4 deletions
+244 -1
View File
@@ -18,6 +18,12 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
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
@@ -389,7 +395,244 @@ def cmd_decide(argv):
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide}
# ══════════════════════════════════════════════════════════════════════════
# 三個 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: