45adc10a52
衝突只有 docs/TESTING.md 一處,兩邊都留: main 的 A16(gate-ok)/A17(未推警察)/A18(信標)原樣保留, 我那節改編號成 A19——main 上 A16、A17 已經被 #90 與 #99 各佔一次,不動別人的。 接上 inkstone/ISEP#82 帶進來的 hooks/lib/mainline.py(不自己養第二套判斷): · 有標主線 ⇒ 只排除「屬於主線那一條」的票;掛在**別條** milestone 的仍算債 (那條線現在沒有人在推,正是它躺著的原因) · 沒標主線 ⇒ 退回保守做法:掛 open milestone 的一律不領,並在清單上照實說沒標 · 別條 milestone 已逾期 ⇒ +15「答應過人家的日期已經過了」 測試 40 → 47 條(+7 條全給主線這段),並把 ISEP_COUNTDOWN_STATE_DIR 也隔離: 不然這支測試的結果會取決於「這台機器現在標了哪條主線」。 盤點數字在合併後的樹上重數: hooks/*.sh = 54、hooks.json "command": = 71(本 PR 沒新增 hook,兩個都沒變) scripts 一層檔案 = 39(本 PR +2)⇒ plugin.json description 37→39 版本號沒動(0.12.0,總管統一定版)
560 lines
23 KiB
Python
Executable File
560 lines
23 KiB
Python
Executable File
#!/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=<json 檔>` 就完全不打網路(測試用)。
|
||
狀態檔位置可用 `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 <owner/repo#N> [更多…]")
|
||
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 <owner/repo#N> [更多…]")
|
||
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:]))
|