#!/usr/bin/env python3 """debt-worklist — 還債線:每天固定撈一次「今天可以領什麼」,整包整包地領。 leo 2026-08-27(inkstone/ISEP#83 的來由,他當場訂正總管的誤解): 「這是兩件不同的事,你根本沒搞清楚,距離是在確定有個緊急事件要儘速達成時, 現在每天都在追新的,所以**要用 Routine 去消化舊的**,可以有多個條件」 ⇒ **兩條線,各用各的判準**,不是二選一: 緊急線 有事要趕 → 用「距離目標的遠近」排(=掛在 open milestone 上的那些) 還債線 每天消化舊的 → 用多條件排(事故/擋人/承諾/票齡/估工) ← 本工具 病根:全 org 229 張 open 票,每天在動的只有掛在 active milestone 上的那些。 沒掛的不是不重要,是**沒有任何機制會去拿它們**。`worklist-guard.sh` 擋的是 「這一輪已經領到的工作沒做完就收工」——它不會去把舊票撈出來。本工具補的是供給面。 三個動詞: debt-worklist list 撈一次,排好序印出來(預設動作) debt-worklist claim <票...> 領走(今天領的,明天就不會再出現在清單上) debt-worklist forget <票...> 放回去(領錯了、或那條線改方向了) 票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` 🔴 **不是輪詢。** 這支腳本自己不會醒——是人跑它、或 SessionStart 那一次順手跑。 不准掛 Gitea Actions/webhook fan-out(CLAUDE.md 避免再被 flag 的硬規則, `issue-handle` 明寫「有事才讀」,換成 Gitea 也不放寬)。 離線可測:設 `ISEP_DEBT_FIXTURE=` 就完全不打網路(測試用)。 狀態檔位置可用 `ISEP_DEBT_STATE_DIR` 改(測試用,預設 `~/.claude/isep-debt/`)。 """ import json import os import re import subprocess import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me" ORG = os.environ.get("ISEP_DEBT_ORG") or "inkstone" # ── 「現在的主線是哪一個」只有一份答案(inkstone/ISEP#82 → hooks/lib/mainline.py)── # 本工具排除的是**緊急線正在管的那些**。在 #82 之前,「哪個是 active」沒有指涉對象 # (14 個 open milestone 同時亮著),所以第一版只能粗暴地「掛 open milestone 就排除」。 # 現在有唯一答案了 ⇒ 接上去,**不要自己養第二套判斷**(兩份必然漂移)。 sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib")) try: import mainline as ML except Exception: # 拿不到共用零件就退回舊行為(fail-open),不讓還債線整個癱掉 ML = None # 等 leo 的票不進「可領清單」——不是它們不重要,是**沒有人能替他領**: # Human/human/exec = 要他親手批示或動手 # s/stage = 已經部到 stage,等他驗(session-start-recall 就是這樣定義的) # 這些的正確動作是「催」不是「領」,而催辦是 inkstone/ISEP#93 的事。本票是供給面。 WAITING_LEO_LABELS = {"Human", "human/exec", "s/stage"} # 已經有人在做/已經有 PR 的狀態:這是「答應過人家的」,債比沒開工的還硬。 STARTED_LABELS = {"s/doing", "s/review"} def die(msg, code=2): print(msg, file=sys.stderr) sys.exit(code) # ── 連線(跟 scripts/ticket 同一套:先找帶憑證的 remote,再退環境變數)──────── def token(): root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() 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 in line: m = re.search(r"//[^:/]+:([^@]+)@", line) if m: return m.group(1) for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"): if os.environ.get(env): return os.environ[env] die(f"""🔴 拿不到 {host} 的 token 擇一: • 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有) • 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""") def api(path): url = f"{HOST}/api/v1{path}" req = urllib.request.Request(url, headers={"Authorization": f"token {token()}"}) try: return json.load(urllib.request.urlopen(req, timeout=60)) except urllib.error.HTTPError as e: die(f"🔴 Gitea {e.code}:{e.read().decode()[:300]}\n (path={path})") except Exception as e: die(f"🔴 打不到 {HOST}:{e}\n (path={path})") # ── 收集:全 org 每個 repo 的 open issue + open PR ────────────────────────── def collect(): """回 (repos, items)。repos 是「我查了哪些」的證明,items 是原始票。 fixture 模式(測試用):完全不打網路,資料從 json 檔讀。 """ fx = os.environ.get("ISEP_DEBT_FIXTURE") if fx: with open(fx) as f: data = json.load(f) return data["repos"], data["items"] repos, items = [], [] for r in api(f"/orgs/{ORG}/repos?limit=100") or []: full = r["full_name"] if not r.get("has_issues", True): repos.append({"name": full, "issues": 0, "prs": 0, "skipped": "issues 關閉"}) continue got, page = [], 1 while True: q = urllib.parse.urlencode({"state": "open", "limit": 50, "page": page}) batch = api(f"/repos/{full}/issues?{q}") or [] got.extend(batch) if len(batch) < 50: break page += 1 if page > 20: # 保險絲:不可能有一千張,有的話是我寫錯了 break for it in got: it.setdefault("repository", {"full_name": full}) n_pr = sum(1 for it in got if it.get("pull_request")) repos.append({"name": full, "issues": len(got) - n_pr, "prs": n_pr}) items.extend(got) return repos, items # ── 工具 ─────────────────────────────────────────────────────────────────── def ref_of(it): return f"{it['repository']['full_name']}#{it['number']}" def labels_of(it): return {l["name"] for l in (it.get("labels") or [])} def is_pr(it): return bool(it.get("pull_request")) def parse_ts(s): if not s: return None try: return datetime.fromisoformat(s.replace("Z", "+00:00")) except Exception: return None def now(): """時鐘可以被定住——測試要能重現,不能今天跑綠明天跑紅。""" inj = os.environ.get("ISEP_DEBT_NOW") if inj: d = parse_ts(inj) if d: return d return datetime.now(timezone.utc) def days_since(s): d = parse_ts(s) if not d: return 0.0 return max(0.0, (now() - d).total_seconds() / 86400) REF_RE = re.compile(r"(?:([\w.-]+)/([\w.-]+))?#(\d+)") def refs_in(it): """從標題+內文抓出它指到的別張票。裸 `#12` 算同 repo。 只認得出「文字上指了誰」——這是零成本的訊號(內文本來就在清單裡)。 Gitea 原生的 dependencies 要一張票打一次 API,229 張=229 次,不值得。 """ owner, repo = it["repository"]["full_name"].split("/") out = set() text = f"{it.get('title') or ''}\n{it.get('body') or ''}" for o, r, n in REF_RE.findall(text): out.add(f"{o or owner}/{r or repo}#{n}") out.discard(ref_of(it)) return out # ── 排序:多個條件,每一條都看得見自己貢獻了幾分 ───────────────────────────── # # leo 給的順序(票上原文):出事的 → 擋住別人的 → 答應過人家的 → 放最久的 → 最快做完的。 # 分數只是把這個順序寫成可重現的東西;**每一項都會印在票旁邊**, # 排出來覺得不對可以直接指著說「這一項給太多」,而不是猜它為什麼在第一名。 def score(it, in_degree): lab = labels_of(it) parts = [] if "type/bug" in lab: parts.append(("事故", 40)) if "p/high" in lab: parts.append(("高優先", 20)) if "p/low" in lab: parts.append(("低優先", -12)) blocked = in_degree.get(ref_of(it), 0) if blocked: parts.append((f"擋住{blocked}張", min(12 * blocked, 36))) if is_pr(it): # 躺著沒結論的 PR 擋的是 merge 那條路(inkstone/ISEP#81 是它的另一半) parts.append(("PR躺著", 25)) started = lab & STARTED_LABELS if started: parts.append((f"已開工({'/'.join(sorted(started))})", 18)) if it.get("assignees") or it.get("assignee"): parts.append(("有人接了", 12)) ms = it.get("milestone") or {} if ms and (ms.get("state") or "open") == "open" and ML is not None: due = ML.due_of(ms) if due and due < now(): # 掛在**別條**(非主線)而且期限已經過去的 milestone ⇒ 這是承諾過的日期, # 而那個日期已經過了。它之所以還在這份清單上,正是因為沒有人在管那條線。 parts.append((f"逾期{int((now() - due).total_seconds() // 86400)}天的承諾", 15)) age = days_since(it.get("updated_at")) if age >= 1: parts.append((f"擱置{int(age)}天", round(min(age * 0.6, 30), 1))) body_len = len(it.get("body") or "") if "type/chore" in lab or body_len < 800: parts.append(("看起來小", 10)) total = round(sum(p[1] for p in parts), 1) return total, parts def pick(items, claimed): """把原始票分成三堆:可領的/緊急線的/要 leo 動手的。並回統計。""" open_refs = {ref_of(it) for it in items} in_degree = {} for it in items: for target in refs_in(it): if target in open_refs: in_degree[target] = in_degree.get(target, 0) + 1 ml = ML.load() if ML is not None else None pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items), "other_ms": 0, "mainline": ml} for it in items: ref = ref_of(it) ms = it.get("milestone") if ms and (ms.get("state") or "open") == "open": if ml is None: # 現在沒有主線 ⇒ 分不出哪一條是緊急線在管的。退回保守做法: # 掛 open milestone 的一律不領(寧可少領,不要跟緊急線搶同一張)。 stats["milestone"] += 1 continue if ML.belongs(ref, ml, ms.get("title")): stats["milestone"] += 1 # 這張真的在主線上 ⇒ 緊急線在管,不重複領 continue # 掛在**別條** milestone 上:那條線現在沒有人在推 ⇒ 它就是債 stats["other_ms"] += 1 if labels_of(it) & WAITING_LEO_LABELS: stats["human"] += 1 # 沒有人能替 leo 領(催辦是 ISEP#93) continue if ref in claimed: stats["claimed"] += 1 # 昨天領走的,今天不再出現 continue s, parts = score(it, in_degree) pool.append({"ref": ref, "title": it.get("title") or "", "score": s, "why": parts, "labels": sorted(labels_of(it)), "pr": is_pr(it), "url": it.get("html_url"), "age": round(days_since(it.get("updated_at")), 1), "refs": sorted(refs_in(it) & open_refs)}) pool.sort(key=lambda d: (-d["score"], d["ref"])) return pool, stats, in_degree # ── 成包:互相指著的必然同包(票上原文「相關的票要成包一起領,不要拆散」)────── # # 🔴 **只認強訊號,寧可少成包**。第一版用「單向文字引用」成包,結果是 # **前 190 張裡有 121 張黏成一包**——因為大家都會順手引用 hub 票。 # 一包 121 張=沒有人領得動=跟沒分包一樣。所以只認這兩種: # ① 互相指著(A 的內文提 B,且 B 的內文提 A)——單向不算 # ② Gitea 原生相依(`ticket subtask` 建的母子票就是這個) # 代價:同一條使用者旅程但沒有互指、也沒建相依的兩張票不會成包。 # 這是誠實的取捨——**寧可漏成包,不要成一包沒人領得動的**。 BUNDLE_CAP = 8 # 一包最多印幾張;超過就說「還有幾張」 DEPS_TOP_K = 25 # 只對前 K 名查原生相依(一張一次 API,不對全部查) def dep_edges(pool, enabled=True): """回 [(a, b)…]:前 K 名的原生相依邊,只留兩端都在 pool 裡的。""" fx = os.environ.get("ISEP_DEBT_FIXTURE") if fx: with open(fx) as f: deps = (json.load(f).get("deps") or {}) in_pool = {d["ref"] for d in pool} return [(a, b) for a, bs in deps.items() for b in bs if a in in_pool and b in in_pool] if not enabled: return [] in_pool = {d["ref"] for d in pool} edges = [] for d in pool[:DEPS_TOP_K]: owner, rest = d["ref"].split("/", 1) repo, num = rest.split("#") try: got = api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or [] except SystemExit: continue for i in got: other = f"{i['repository']['full_name']}#{i['number']}" if other in in_pool: edges.append((d["ref"], other)) return edges def mutual_edges(pool): idx = {d["ref"]: set(d["refs"]) for d in pool} out = [] for ref, targets in idx.items(): for t in targets: if t in idx and ref in idx[t] and ref < t: out.append((ref, t)) return out def bundle(pool, edges): idx = {d["ref"]: d for d in pool} parent = {r: r for r in idx} def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for a, b in edges: if a in idx and b in idx: union(a, b) groups = {} for d in pool: groups.setdefault(find(d["ref"]), []).append(d) out = [] for members in groups.values(): members.sort(key=lambda d: (-d["score"], d["ref"])) out.append({"lead": members[0], "members": members, "score": members[0]["score"]}) out.sort(key=lambda b: (-b["score"], b["lead"]["ref"])) return out # ── 狀態:領走了什麼(明天的清單要看得出昨天領走的不見了)─────────────────── def state_dir(): d = os.environ.get("ISEP_DEBT_STATE_DIR") or os.path.expanduser("~/.claude/isep-debt") os.makedirs(d, exist_ok=True) return d def state_path(): return os.path.join(state_dir(), "claimed.json") def load_state(): try: with open(state_path()) as f: d = json.load(f) except Exception: d = {} d.setdefault("claims", {}) d.setdefault("last_run", None) return d def save_state(d): with open(state_path(), "w") as f: json.dump(d, f, ensure_ascii=False, indent=1) # ── 印 ───────────────────────────────────────────────────────────────────── def print_proof(repos, stats): scanned = [r for r in repos if not r.get("skipped")] n_iss = sum(r["issues"] for r in scanned) n_pr = sum(r["prs"] for r in scanned) print(f"🔎 查過的 repo:{len(repos)} 個({ORG} 全組織)" f" open issue {n_iss}、open PR {n_pr}") for r in sorted(repos, key=lambda x: -(x["issues"] + x["prs"])): mark = f"(跳過:{r['skipped']})" if r.get("skipped") else "" print(f" · {r['name']} issue {r['issues']}/PR {r['prs']} {mark}") ml = stats.get("mainline") if ml: print(f"\n🎯 現在的主線:{ML.ref_of(ml)}「{ml.get('title')}」" f"(唯一答案來自 scripts/mainline,本工具不自己判)") print(f" 掛在**別條** milestone 上的 {stats['other_ms']} 張仍算債" "——那條線現在沒有人在推") else: print("\n🎯 現在沒有標主線(`scripts/mainline set …`)" "⇒ 分不出哪條是緊急線在管的,保守起見:掛 open milestone 的一律不領") print(f"\n 扣掉:掛在 open milestone 上的 {stats['milestone']} 張(那些歸緊急線管)" f"/等 leo 的 {stats['human']} 張" f"(Human・human/exec・s/stage——那些要催不是要領,見 inkstone/ISEP#93)" f"/已經領走的 {stats['claimed']} 張") def print_carry(state, items): """昨天領走的,今天怎麼了——**這一段就是「看得出領走的不在清單上」**。""" claims = state.get("claims") or {} if not claims: return open_refs = {ref_of(it) for it in items} done = [r for r in claims if r not in open_refs] doing = [r for r in claims if r in open_refs] print(f"\n📦 之前領走的 {len(claims)} 張:已關 {len(done)}、還開著 {len(doing)}") for r in sorted(doing): c = claims[r] print(f" ◻ {r} ({c.get('at', '?')[:10]} 領的){c.get('title', '')[:40]}") for r in sorted(done): print(f" ✅ {r} 已關") if doing: print(" 🔴 還開著的沒有回到可領清單裡——做完就關票,或 " "`debt-worklist forget <票>` 把它放回去。") def print_bundle(i, b, verbose): lead = b["lead"] kind = "PR" if lead["pr"] else "票" print(f"\n {i}. [{lead['score']}] {lead['ref']}({kind})") print(f" {lead['title'][:72]}") print(" 為什麼排這裡:" + "、".join(f"{n}{v:+g}" for n, v in lead["why"])) if lead["labels"]: print(f" 標籤:{lead['labels']}") others = b["members"][1:] if others: print(f" ↳ 同一包(互相指著/原生相依,要一起領)共 {len(b['members'])} 張:") shown = others if verbose else others[:BUNDLE_CAP] for m in shown: print(f" · [{m['score']}] {m['ref']} {m['title'][:56]}") if len(others) > len(shown): print(f" · …另外 {len(others) - len(shown)} 張(`--all` 看全部)") def cmd_list(argv): top = int(argv[argv.index("--top") + 1]) if "--top" in argv else 6 verbose = "--all" in argv as_json = "--json" in argv repos, items = collect() state = load_state() pool, stats, _ = pick(items, set(state.get("claims") or {})) edges = mutual_edges(pool) + dep_edges(pool, enabled="--no-deps" not in argv) bundles = bundle(pool, edges) if as_json: print(json.dumps({ "at": now().isoformat(), "repos": repos, "stats": stats, "claimed": sorted(state.get("claims") or {}), "bundles": [{"score": b["score"], "members": [{"ref": m["ref"], "score": m["score"], "title": m["title"], "why": m["why"]} for m in b["members"]]} for b in bundles], }, ensure_ascii=False, indent=1)) else: print(f"🧾 還債清單 {now().strftime('%Y-%m-%d %H:%M UTC')}\n") print_proof(repos, stats) print_carry(state, items) print(f"\n── 今天可以領的:{len(pool)} 張,收斂成 {len(bundles)} 包 " f"────────────────────") if not pool: # 🔴 空清單要說話。默默結束=人分不出「查過沒東西」跟「根本沒查」。 print("\n ✅ **查詢跑過了,結果是零。**" f"\n {len(repos)} 個 repo 都掃過(見上面那份清單)," "沒有一張是「沒掛 milestone、沒等 leo、還沒被領走」的。" "\n 這代表舊債清乾淨了——不是查詢壞掉。") for i, b in enumerate(bundles[:len(bundles) if verbose else top], 1): print_bundle(i, b, verbose) if not verbose and len(bundles) > top: print(f"\n …另外 {len(bundles) - top} 包(`--top N` 或 `--all` 看更多)") if pool: first = " ".join(m["ref"] for m in bundles[0]["members"][:BUNDLE_CAP]) print(f""" ── 領走它 ────────────────────────────────────────────────── debt-worklist claim {first} 領走 = 今天要做它。領了之後它就不會再出現在這份清單上, 直到你把票關掉、或 `debt-worklist forget` 把它放回去。 🔴 **整包一起領**:同一包是互相指著的,拆開做會做到一半發現卡在另一張。""") state["last_run"] = now().isoformat() save_state(state) return 0 def _titles(items): return {ref_of(it): (it.get("title") or "") for it in items} def cmd_claim(argv): refs = [a for a in argv if not a.startswith("-")] if not refs: die("用法:debt-worklist claim [更多…]") for r in refs: if not re.match(r"^[\w.-]+/[\w.-]+#\d+$", r): die(f"🔴 票的寫法是 owner/repo#N,你給的是:{r}") _, items = collect() titles = _titles(items) unknown = [r for r in refs if r not in titles] if unknown: die("🔴 這幾張不在 open 清單裡(打錯了?已經關了?):" + "、".join(unknown)) state = load_state() for r in refs: state["claims"][r] = {"at": now().isoformat(), "title": titles[r]} save_state(state) print(f"📦 領走 {len(refs)} 張,明天的清單不會再出現它們:") for r in refs: print(f" · {r} {titles[r][:60]}") print("\n🔴 領了就是今天要做它。做完關票;改方向就 `debt-worklist forget`。") return 0 def cmd_forget(argv): refs = [a for a in argv if not a.startswith("-")] if not refs: die("用法:debt-worklist forget [更多…]") state = load_state() gone = [r for r in refs if state["claims"].pop(r, None) is not None] save_state(state) print(f"↩️ 放回清單 {len(gone)} 張:" + ("、".join(gone) or "(本來就沒領過)")) return 0 CMDS = {"list": cmd_list, "claim": cmd_claim, "forget": cmd_forget} if __name__ == "__main__": if len(sys.argv) < 2: sys.exit(cmd_list([])) if sys.argv[1] in ("-h", "--help", "help"): print(__doc__) sys.exit(0) if sys.argv[1] not in CMDS: print(__doc__) sys.exit(2) sys.exit(CMDS[sys.argv[1]](sys.argv[2:]))