Files
ISEP/scripts/milestone-account
isep-hand d4bf172990 主線成員改由相依邊決定,不靠同名里程碑;adopt 別 repo 的票=加相依到 hub 票;帳本落在 InkStoneCo/system-dev(inkstone/ISEP#133 → 6743)
leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個
milestone,不同 repo 用指針」。上一版 scripts/mainline 檔頭把現場的樣子(5 個同名)寫成
「既有做法」,成員也照標題跨 repo 收;總管照它做、抄進 ops-facts、還在 arcrun-rag 補建了一個同名的。

改了什麼(判準全部是 Gitea 欄位,沒有標題比對):
- hooks/lib/mainline.py:collect_members()=hub 里程碑裡的票+hub 票,沿 /dependencies 邊
  (跨 repo)收下去;belongs() 要同時拿到「掛哪個 milestone」+「誰把它當相依」才准說「不屬於」,
  只知一半回 None(閘放行);project_roots() 給主線檔與帳本共用
- scripts/mainline:set --hub <票>、adopt 別 repo ⇒ POST 相依到 hub 票(--via 可指定成員),
  沒 hub 票時擋下並給兩條走得通的路,不建同名里程碑;has 查 /blocks;
  refresh --members 才重收(真 Gitea 25 張要 19 秒,SessionStart 成本不變)
- scripts/ticket:add_dependency() 一份(subtask 與 adopt 共用,409 冪等);pick 主線組走成員清單,
  逾期組一個里程碑物件一組;claim 的主線判定改看相依;pickable(None) 給靠邊進來的票
- hooks/mainline-focus-guard.sh:新鮮查詢問 milestone+/blocks,補收出路明講不要開同名里程碑
- scripts/milestone-account:帳本找家用同一把尺,退回原始碼目錄只在它有 .git(plugin 快取沒有)

測試:新 hooks/tests/mainline-members-by-dependency.test.sh 56 條(假 Gitea 照真的回 201/409);
scripts/test-ticket-pick.sh 48→53 條(池子改用相依邊,補「同名沒邊 ⇒ 不抓」);
既有 mainline-focus-guard/repo-mirror/gitea-fallback/idle/milestone-account/debt-worklist/
handoff-writeback 全綠。真 Gitea 唯讀實跑:set --hub inkstone/InkStoneCo#44 收到 25 張、5 個 repo,
mira#6/ISEP#130/ISEP#140/arcrun-rag#104 都在,不必新增任何邊。

版本:待總管定版(改了會被載入的 hook/scripts)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 10:40:55 +00:00

662 lines
29 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 _project_roots():
"""跟 `hooks/lib/mainline.py::project_roots()` 同一把尺(那裡是唯一定義;載不到才退回同形狀的內建)。"""
try:
import importlib.machinery
import importlib.util
path = os.path.join(ROOT, "hooks", "lib", "mainline.py")
loader = importlib.machinery.SourceFileLoader("isep_mainline_for_account", path)
spec = importlib.util.spec_from_file_location("isep_mainline_for_account", path, loader=loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod.project_roots()
except Exception:
roots = []
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
if r:
for c in (r, os.path.join(r, "InkStoneCo")):
if c not in roots:
roots.append(c)
return roots
def _resolve_dir():
"""帳本住哪裡。
🔴 **不能住在 plugin 目錄裡**ISEP 是用 marketplace 裝的,
`claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零,
而這張票的全部價值就在「累積」。
inkstone/ISEP#133 實撞:雲端的 `$CLAUDE_PROJECT_DIR` 是薄殼根(沒有 `system-dev/`),
上一版於是退到「原始碼所在 repo 的 system-dev/」——而雲端的原始碼就在
`~/.claude/plugins/cache/inkstone/isep/<版本>/`,那天記的 5 筆帳下一個 session 就不見。
順序:環境變數 → 專案根/專案根底下的 `InkStoneCo/`/cwd 同兩層(跟主線檔同一把尺,
帳本本來就屬於 InkStoneCo 那一層)→ 這份原始碼所在 repo 的 `system-dev/`**但只在
它是一個 git 工作樹時**(有 `.git`=真的 checkoutplugin 快取沒有 `.git`)→ `~/.claude/`。
"""
v = os.environ.get("MILESTONE_ACCOUNT_DIR")
if v:
return v
for base in _project_roots():
if os.path.isdir(os.path.join(base, "system-dev")):
return os.path.join(base, "system-dev", "estimates")
if os.path.exists(os.path.join(ROOT, ".git")) and os.path.isdir(os.path.join(ROOT, "system-dev")):
return os.path.join(ROOT, "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()