跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(scripts/milestone-account)
This commit is contained in:
@@ -0,0 +1,634 @@
|
|||||||
|
#!/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 的 token(remote 沒帶憑證,也沒有 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()
|
||||||
Reference in New Issue
Block a user