Files
ISEP/scripts/milestone-account
T

635 lines
27 KiB
Python
Raw Permalink 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
"""milestone-account — 每次結案都留下「估多久/花多久/差多少/為什麼」(inkstone/ISEP#85)。
leo 的問題(票上原文):「leo 2026-08-27 說今天『**拖時間**』。但拖了多久、
比預計多拖多少、為什麼拖——**沒有任何數字**⋯⋯每次事後只能用『這次比較慢』
這種話帶過,下次還是一樣。」
milestone-account codes 七個代號是哪七個(封閉集合)
milestone-account list --org inkstone [--overdue] 現在每個里程碑的三個數字
milestone-account audit inkstone/ISEP#12 從 Gitea 時間軸算證據
milestone-account close inkstone/ISEP#12 --reason idle [--dry-run]
milestone-account report 累計:哪一種原因最常發生
milestone-account ledger-path 帳本檔在哪(給閘問的)
🔴 這支工具的三條,全部長在機器上(不是寫在規範裡靠人記得):
① **三個數字自己算,不用手填**:期限與實際都取自 Gitea 的 `created_at`
`due_on``closed_at`。手填的數字沒有人會去對,等於沒有。
② **差超過 25%(超時或提早都算)就一定要挑一個代號**,挑不出來不准結案。
提早太多不是好消息,是**當初估太鬆**——所以兩個方向同一條線。
③ **代號要有時間軸撐得住**:`audit` 先從 Gitea 的時間軸算出哪幾個訊號成立,
`close` 只接受**訊號成立的那幾個代號**;一個都沒成立時,唯一合法的代號
就是 `misestimate`(=票上那句「以上都沒有,但還是差很多」)。
⇒ **不採信我自己說的**,這是票上寫死的。
🔴 代號是**封閉集合**,這一點是刻意的——票上原文:「**能統計的前提是分類有限**」。
真的歸不進去才提案加一個,而那要走 leo 核准(開票,不是自己加)。
離線測試:`scripts/test-milestone-account.sh`(走 `MILESTONE_ACCOUNT_FIXTURE`,不打網路)。
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me")
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def _resolve_dir():
"""帳本住哪裡。
🔴 **不能住在 plugin 目錄裡**ISEP 是用 marketplace 裝的,
`claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零,
而這張票的全部價值就在「累積」。
順序:環境變數 → 這個 session 的專案根底下的 `system-dev/`(總管的跨專案知識庫,
帳本本來就屬於那一層)→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。
"""
v = os.environ.get("MILESTONE_ACCOUNT_DIR")
if v:
return v
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
if base and os.path.isdir(os.path.join(base, "system-dev")):
return os.path.join(base, "system-dev", "estimates")
return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates")
DIR = _resolve_dir()
LEDGER = os.path.join(DIR, "ledger.jsonl")
FIXTURE = os.environ.get("MILESTONE_ACCOUNT_FIXTURE", "")
DRY = False
THRESHOLD = 25.0 # 差超過幾 % 就一定要挑代號(票上寫死「四分之一」)
# ── 封閉集合:七個代號,一個都不准多、不准少 ──────────────────────────────
CODES = [
("idle", "磨洋工", "小事拖成大事", "時間軸上有長長的空白"),
("sidetrack", "分心跳線", "講到別的就跑去做", "期間出現不屬於這條線的動作"),
("undispatched", "忘了派工", "該派沒派,票停著", "票很久沒有人被指派"),
("cherry-pick", "偏科", "挑喜歡的做,難的擱著", "完成順序跟優先順序倒過來"),
("waiting", "等答案", "問了就原地等,沒去做別的", "等待期間其他票零進度"),
("scope-creep", "範圍變大", "一直塞新東西進來", "補收的次數、票數成長曲線"),
("misestimate", "純粹估錯", "過程沒異常,就是估錯", "以上都沒有,但還是差很多"),
]
CODE_IDS = [c[0] for c in CODES]
LABEL = {c[0]: c[1] for c in CODES}
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE
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]
return ""
_FX = None
def fx():
global _FX
if _FX is None:
try:
with open(FIXTURE) as f:
_FX = json.load(f)
except Exception:
die("🔴 讀不到 MILESTONE_ACCOUNT_FIXTURE%s" % FIXTURE)
return _FX
def api(path, payload=None, method=None):
verb = method or ("POST" if payload is not None else "GET")
if FIXTURE:
if verb == "GET":
return (fx().get("api") or {}).get(path)
print(" [fixture] %s %s %s" % (verb, path, json.dumps(payload or {})))
return {}
if DRY and verb != "GET":
print(" [dry-run] %s %s %s" % (verb, path, json.dumps(payload or {})))
return {}
tk = token()
if not tk:
die("🔴 拿不到 %s 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE" % HOST)
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
HOST + "/api/v1" + path, data=data, method=verb,
headers={"Authorization": "token " + tk, "Content-Type": "application/json"})
try:
raw = urllib.request.urlopen(req, timeout=30).read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
if e.code == 404:
return None
die("🔴 Gitea %d on %s %s%s" % (e.code, verb, path, e.read().decode()[:200]))
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
# ── 時間 ─────────────────────────────────────────────────────────────────
_TS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})"
r"(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$")
def ts(s):
"""Gitea 的時間字串 → epoch 秒。認得 Z 與 +08:00 兩種尾巴;認不得回 None。"""
if not s:
return None
m = _TS.match(str(s).strip())
if not m:
return None
y, mo, d, h, mi, se, off = m.groups()
t = time.mktime((int(y), int(mo), int(d), int(h), int(mi), int(se), 0, 1, 0)) \
- time.timezone
if off and off not in ("Z", "z"):
sign = 1 if off[0] == "+" else -1
off = off[1:].replace(":", "")
t -= sign * (int(off[:2]) * 3600 + int(off[2:]) * 60)
return t
def now():
v = os.environ.get("MILESTONE_ACCOUNT_NOW")
return ts(v) if v else time.time()
def days(sec):
return sec / 86400.0
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die("🔴 寫法是 owner/repo#里程碑編號,你給的是:%s" % s)
return m.group(1), m.group(2), int(m.group(3))
# ── 三個數字 ─────────────────────────────────────────────────────────────
def numbers(ms):
"""回 (planned_days, actual_days, deviation_pct)。算不出來的格子回 None。"""
c, due = ts(ms.get("created_at")), ts(ms.get("due_on"))
end = ts(ms.get("closed_at")) or now()
if c is None:
return None, None, None
actual = days(end - c)
if due is None or due <= c:
return None, actual, None
planned = days(due - c)
return planned, actual, (actual - planned) / planned * 100.0
def fmt(v, unit=""):
return "—" if v is None else ("%.1f%s" % (v, unit))
def three_lines(ms, planned, actual, dev):
return (" 當初訂的期限 %s天 %s%s\n"
" 實際花掉的  %s天 (到 %s\n"
" 差了     %s"
% (fmt(planned), (ms.get("created_at") or "?")[:10],
(ms.get("due_on") or "沒填期限")[:10],
fmt(actual), (ms.get("closed_at") or "現在")[:10],
"算不出來(期限沒填,或期限不晚於建立日)" if dev is None
else "%+.0f%%%s" % (dev, " 🔴 超過 ±25%,要挑代號"
if abs(dev) > THRESHOLD else "")))
# ── 從 Gitea 時間軸取證據 ────────────────────────────────────────────────
def fetch_issues(owner, repo):
rows, page = [], 1
while page <= 5:
r = api("/repos/%s/%s/issues?state=all&type=issues&limit=50&page=%d"
% (owner, repo, page))
if not isinstance(r, list) or not r:
break
rows.extend(r)
if len(r) < 50:
break
page += 1
return rows
def fetch_comments(owner, repo, since):
q = "?limit=100"
if since:
q += "&since=" + urllib.parse.quote(since)
r = api("/repos/%s/%s/issues/comments%s" % (owner, repo, q))
return r if isinstance(r, list) else []
def prio(issue):
"""優先度:p/high=3、p/med=2、p/low=1、沒標=0。"""
v = 0
for l in issue.get("labels") or []:
n = (l.get("name") or "").lower()
if n in ("p/high", "p/urgent", "p0", "p1"):
v = max(v, 3)
elif n in ("p/med", "p/medium", "p2"):
v = max(v, 2)
elif n in ("p/low", "p3"):
v = max(v, 1)
return v
def evidence(owner, repo, ms):
"""把 Gitea 時間軸算成七個訊號。回 (signals, detail)。
🔴 這裡**一句話都不讀**:全部只看時間戳、指派欄位、標籤、票的歸屬。
票上寫死「不採信我自己說的」,所以判準不能碰任何自然語言。
"""
start = ts(ms.get("created_at"))
end = ts(ms.get("closed_at")) or now()
span = max(end - start, 1.0)
mid = ms.get("id")
issues = fetch_issues(owner, repo)
if issues is None:
issues = []
mine = [i for i in issues if (i.get("milestone") or {}).get("id") == mid]
others = [i for i in issues if (i.get("milestone") or {}).get("id") != mid]
comments = fetch_comments(owner, repo, ms.get("created_at"))
mine_ids = set(i.get("number") for i in mine)
def cnum(c):
u = c.get("issue_url") or c.get("html_url") or ""
m = re.search(r"/(?:issues|pulls)/(\d+)", u)
return int(m.group(1)) if m else c.get("issue_number")
ev_mine, ev_other = [], []
for i in mine:
for k in ("created_at", "closed_at"):
t = ts(i.get(k))
if t and start <= t <= end:
ev_mine.append(t)
for c in comments:
t = ts(c.get("created_at"))
if not t or not (start <= t <= end):
continue
(ev_mine if cnum(c) in mine_ids else ev_other).append(t)
for i in others:
t = ts(i.get("created_at"))
if t and start <= t <= end:
ev_other.append(t)
marks = sorted([start] + ev_mine + [end])
gap, gap_at = 0.0, None
for a, b in zip(marks, marks[1:]):
if b - a > gap:
gap, gap_at = b - a, a
sig, det = {}, {}
# ① 磨洋工:時間軸上有長長的空白
sig["idle"] = days(gap) >= 2.0 and gap / span >= 0.35
det["idle"] = ("最長空白 %.1f 天(佔整段 %.0f%%),起點 %s"
% (days(gap), gap / span * 100,
time.strftime("%Y-%m-%d", time.gmtime(gap_at)) if gap_at else "?"))
# ② 分心跳線:期間出現不屬於這條線的動作
tot = len(ev_mine) + len(ev_other)
ratio = (len(ev_other) / tot) if tot else 0.0
sig["sidetrack"] = len(ev_other) >= 5 and ratio >= 0.30
det["sidetrack"] = ("這段期間 %d 個動作在這條線上、%d 個在別的票上(%.0f%%"
% (len(ev_mine), len(ev_other), ratio * 100))
# ③ 忘了派工:票很久沒有人被指派
naked = [i for i in mine if not (i.get("assignees") or i.get("assignee"))
and days(end - (ts(i.get("created_at")) or end)) >= days(span) * 0.5]
sig["undispatched"] = len(naked) > 0
det["undispatched"] = ("%d 張票整段期間沒有人被指派:%s"
% (len(naked), ", ".join("#%s" % i.get("number")
for i in naked[:6])) if naked
else "每一張票都有人被指派過")
# ④ 偏科:完成順序跟優先順序倒過來
inv = 0
closed = [i for i in mine if ts(i.get("closed_at"))]
openv = [i for i in mine if not ts(i.get("closed_at"))]
for a in closed:
for b in closed:
if prio(a) < prio(b) and ts(a["closed_at"]) < ts(b["closed_at"]):
inv += 1
for a in closed:
for b in openv:
if prio(a) < prio(b):
inv += 1
sig["cherry-pick"] = inv >= 2
det["cherry-pick"] = ("低優先先收掉、高優先還晾著的配對 %d 組" % inv)
# ⑤ 等答案:等待期間其他票零進度
waitset = [i for i in mine
if any((l.get("name") or "").lower() == "human"
for l in (i.get("labels") or []))]
wait_days = 0.0
for i in waitset:
s = ts(i.get("created_at")) or start
e = ts(i.get("closed_at")) or end
wait_days = max(wait_days, days(e - s))
moved = sum(1 for i in mine
if ts(i.get("closed_at")) and i not in waitset)
sig["waiting"] = bool(waitset) and wait_days >= days(span) * 0.30 and moved == 0
det["waiting"] = ("掛 Human 的票 %d 張、最長等 %.1f 天;期間其他票關掉 %d 張"
% (len(waitset), wait_days, moved))
# ⑥ 範圍變大:補收的次數、票數成長曲線
late = [i for i in mine
if (ts(i.get("created_at")) or start) > start + span * 0.25]
sig["scope-creep"] = len(late) >= 1 and len(mine) and len(late) / len(mine) >= 0.20
det["scope-creep"] = ("開跑之後才補進來的票 %d%d 張:%s"
% (len(late), len(mine),
", ".join("#%s" % i.get("number") for i in late[:6])))
# ⑦ 純粹估錯:以上都沒有
sig["misestimate"] = not any(sig[c] for c in CODE_IDS if c != "misestimate")
det["misestimate"] = ("上面六個訊號%s成立"
% ("一個都沒有" if sig["misestimate"] else "有"))
return sig, det
def get_milestone(owner, repo, num):
ms = api("/repos/%s/%s/milestones/%d" % (owner, repo, num))
if not isinstance(ms, dict) or not ms.get("id"):
die("🔴 抓不到里程碑 %s/%s#%d(編號對嗎?)" % (owner, repo, num))
return ms
# ── 指令 ─────────────────────────────────────────────────────────────────
def cmd_codes(argv):
print("七個代號(封閉集合,inkstone/ISEP#85)——「能統計的前提是分類有限」\n")
for cid, label, plain, how in CODES:
print(" %-13s %-8s %-22s 從哪看出來:%s" % (cid, label, plain, how))
print("\n真的歸不進去 ⇒ 開一張票請 leo 核准加一個,**不要自己加**。")
def _repos(argv):
rs = [r for r in (argv[i + 1] for i, a in enumerate(argv)
if a == "--repo" and i + 1 < len(argv))]
if rs:
return rs
org = None
for i, a in enumerate(argv):
if a == "--org" and i + 1 < len(argv):
org = argv[i + 1]
if not org:
die("用法:milestone-account list --org inkstone [--overdue] 或 --repo owner/repo")
rows = api("/orgs/%s/repos?limit=100" % org)
if not isinstance(rows, list):
die("🔴 撈不到 %s 底下的 repo" % org)
return [r["full_name"] for r in rows]
def cmd_list(argv):
overdue = "--overdue" in argv
n = 0
for full in _repos(argv):
owner, repo = full.split("/", 1)
mss = api("/repos/%s/%s/milestones?state=open&limit=50" % (owner, repo))
if not isinstance(mss, list):
continue
for ms in mss:
planned, actual, dev = numbers(ms)
late = dev is not None and dev > 0
if overdue and not late:
continue
n += 1
print("%s %-34s %-30s%s天/已 %s天/%s"
% ("🔴" if (dev is not None and abs(dev) > THRESHOLD) else " ",
"%s#%s" % (full, ms.get("id")), (ms.get("title") or "")[:28],
fmt(planned), fmt(actual),
"—" if dev is None else "%+.0f%%" % dev))
print("\n%d%s里程碑。🔴 差超過 ±25%%,結案時一定要挑一個代號。"
% (n, "逾期的 " if overdue else "open "))
print("挑代號之前先跑:milestone-account audit <owner/repo#編號>")
def cmd_audit(argv):
if not argv:
die("用法:milestone-account audit <owner/repo#里程碑編號>")
owner, repo, num = parse_ref(argv[0])
ms = get_milestone(owner, repo, num)
planned, actual, dev = numbers(ms)
print("【%s/%s#%d%s\n" % (owner, repo, num, ms.get("title") or ""))
print(three_lines(ms, planned, actual, dev))
sig, det = evidence(owner, repo, ms)
print("\n【時間軸怎麼說】(全部取自 Gitea 的時間戳/欄位,一句話都沒讀)")
for cid, label, _plain, _how in CODES:
print(" %s %-13s %-8s %s" % ("🔴" if sig[cid] else " ", cid, label, det[cid]))
ok = [c for c in CODE_IDS if sig[c]]
print("\n可以挑的代號:%s" % " / ".join(ok))
print("結案:milestone-account close %s/%s#%d --reason %s" % (owner, repo, num, ok[0]))
def _ledger():
rows = []
try:
with open(LEDGER) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except Exception:
pass
except Exception:
pass
return rows
def cmd_close(argv):
if not argv:
die("用法:milestone-account close <owner/repo#編號> [--reason 代號] [--dry-run]")
owner, repo, num = parse_ref(argv[0])
ref = "%s/%s#%d" % (owner, repo, num)
reason = None
for i, a in enumerate(argv):
if a == "--reason" and i + 1 < len(argv):
reason = argv[i + 1]
ms = get_milestone(owner, repo, num)
planned, actual, dev = numbers(ms)
print("【%s%s\n" % (ref, ms.get("title") or ""))
print(three_lines(ms, planned, actual, dev))
sig, det = evidence(owner, repo, ms)
legal = [c for c in CODE_IDS if sig[c]]
need = dev is None or abs(dev) > THRESHOLD
if need and not reason:
die("\n🚫 **差 %s,超過 ±25%%——挑不出代號不准結案。**\n\n"
"【inkstone/ISEP#85】「差超過四分之一(不管是超時還是提早太多,\n"
" **提早太多代表當初估太鬆**)就要挑一個原因,而且**只能從固定的幾個裡面挑**,\n"
" 不准用一段話帶過。」\n\n"
"時間軸撐得住的代號:%s\n"
"%s\n"
" 再送一次:milestone-account close %s --reason %s\n"
" 想看全部七個:milestone-account codes 看證據:milestone-account audit %s"
% ("算不出來(期限沒填)" if dev is None else "%+.0f%%" % dev,
" / ".join(legal),
"\n".join(" %-13s %s" % (c, det[c]) for c in legal), ref, legal[0], ref))
if reason:
if reason not in CODE_IDS:
die("\n🚫 `%s` 不是那七個代號之一。\n"
" 【inkstone/ISEP#85】代號是**封閉集合**——「能統計的前提是分類有限」。\n"
" 七個:%s\n"
" 真的歸不進去 ⇒ 開票請 leo 核准加一個,不要自己造。"
% (reason, " / ".join(CODE_IDS)))
if reason not in legal:
die("\n🚫 `%s`%s**時間軸撐不住**——不接受。\n\n"
" 【inkstone/ISEP#85】「證據一律從 Gitea 的時間軸取"
"(留言、指派、標籤、commit 都有時間戳),**不採信我自己說的**。」\n\n"
" 這個代號的訊號:%s\n"
" 撐得住的是:%s\n"
" (訊號真的判錯了 ⇒ 那是 audit 的 bug,去修偵測器,"
"不是在這裡繞過去——這條線的意義就是「可以統計、可以迭代」。)"
% (reason, LABEL[reason], det[reason], " / ".join(legal)))
rec = {"ref": ref, "title": ms.get("title") or "",
"created_at": ms.get("created_at"), "due_on": ms.get("due_on"),
"closed_at": ms.get("closed_at") or time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(now())),
"planned_days": None if planned is None else round(planned, 2),
"actual_days": None if actual is None else round(actual, 2),
"deviation_pct": None if dev is None else round(dev, 1),
"over_threshold": bool(need),
"reason": reason, "reason_label": LABEL.get(reason or "", ""),
"signals": {c: bool(sig[c]) for c in CODE_IDS},
"evidence": {c: det[c] for c in CODE_IDS},
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now()))}
if DRY:
print("\n[dry-run] 會寫進 %s" % LEDGER)
print(json.dumps(rec, ensure_ascii=False, indent=2)[:900])
print(" [dry-run] PATCH /repos/%s/%s/milestones/%d {\"state\": \"closed\"}"
% (owner, repo, num))
print("[dry-run] 一筆都沒有真的寫出去。")
return
# 🔴 順序:**先記帳、再關**。反過來的話,關成功而記帳失敗,
# 那個里程碑就永遠不會出現在統計裡——而那正是這張票要解的病。
os.makedirs(DIR, exist_ok=True)
with open(LEDGER, "a") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print("\n✅ 已記帳:%s 估 %s天/花 %s天/%s%s"
% (ref, fmt(planned), fmt(actual),
"—" if dev is None else "%+.0f%%" % dev,
" 原因=%s%s" % (reason, LABEL[reason]) if reason else ""))
if (ms.get("state") or "") != "closed":
api("/repos/%s/%s/milestones/%d" % (owner, repo, num),
{"state": "closed"}, method="PATCH")
print("✅ 里程碑已關閉")
else:
print("ℹ️ 里程碑本來就是 closed,只補記帳")
write_report()
print("📊 累計統計已更新:%s" % os.path.join(DIR, "REPORT.md"))
def write_report():
rows = _ledger()
cnt = {}
for r in rows:
if r.get("reason"):
cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
lines = ["# 估多久/花多久/差多少——累計統計", "",
"> 這份檔案由 `scripts/milestone-account` 自動重寫,**不要手改**。",
"> 【inkstone/ISEP#85】「每次事後只能用『這次比較慢』這種話帶過,"
"下次還是一樣。」", "",
"- 累計結案 **%d** 個里程碑" % len(rows)]
if devs:
lines.append("\t- 平均偏差 **%+.0f%%**(正=超時,負=提早)"
% (sum(devs) / len(devs)))
lines.append("\t- 超過 ±25%% 的有 **%d** 個"
% sum(1 for d in devs if abs(d) > THRESHOLD))
lines.append("- 哪一種原因最常發生(這才是迭代的依據)")
if not cnt:
lines.append("\t- 目前還沒有任何被挑過的代號。")
for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
lines.append("\t- **%s%s** ×%d" % (LABEL.get(cid, cid), cid, n))
lines.append("- 逐筆")
for r in sorted(rows, key=lambda r: r.get("recorded_at") or "", reverse=True):
lines.append("\t- `%s` %s" % (r["ref"], r.get("title", "")))
lines.append("\t\t- 估 %s 天/花 %s 天/差 %s"
% (r.get("planned_days"), r.get("actual_days"),
"—" if r.get("deviation_pct") is None
else "%+.0f%%" % r["deviation_pct"]))
if r.get("reason"):
lines.append("\t\t- 原因:**%s%s**" % (r.get("reason_label"), r["reason"]))
lines.append("\t\t- 時間軸證據:%s"
% (r.get("evidence") or {}).get(r["reason"], "—"))
try:
os.makedirs(DIR, exist_ok=True)
with open(os.path.join(DIR, "REPORT.md"), "w") as f:
f.write("\n".join(lines) + "\n")
except Exception as e:
die("🔴 寫不進 %s%s" % (DIR, e))
def cmd_report(argv):
rows = _ledger()
if not rows:
print("(帳本還是空的:%s" % LEDGER)
print("關掉第一個里程碑就會有第一筆:milestone-account close <owner/repo#編號>")
return
cnt = {}
for r in rows:
if r.get("reason"):
cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
print("累計結案 %d 個里程碑" % len(rows))
if devs:
print("平均偏差 %+.0f%%(正=超時,負=提早);超過 ±25%% 的有 %d 個"
% (sum(devs) / len(devs), sum(1 for d in devs if abs(d) > THRESHOLD)))
print("\n哪一種原因最常發生:")
if not cnt:
print(" (還沒有任何被挑過的代號)")
for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
print(" %-13s %-8s ×%d %s" % (cid, LABEL.get(cid, cid), n, "█" * n))
write_report()
print("\n人看的版本:%s" % os.path.join(DIR, "REPORT.md"))
def main():
global DRY
argv = sys.argv[1:]
if "--dry-run" in argv:
DRY = True
argv = [a for a in argv if a != "--dry-run"]
if not argv:
print(__doc__)
return
cmd, rest = argv[0], argv[1:]
table = {"codes": cmd_codes, "list": cmd_list, "audit": cmd_audit,
"close": cmd_close, "report": cmd_report,
# 給 hooks/milestone-account-guard.sh 問「帳本在哪」用的。
# 讓「怎麼找到帳本」只有一份答案,閘不必自己再實作一次解析。
"ledger-path": lambda a: print(LEDGER)}
if cmd not in table:
print(__doc__)
die("🔴 沒有這個子指令:%s" % cmd)
table[cmd](rest)
if __name__ == "__main__":
main()