dsh-task-ledger 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cordis.patch.yml +6 -0
- package/dist/host/board.d.ts +54 -0
- package/dist/host/board.js +125 -0
- package/dist/host/ledger-read.d.ts +20 -0
- package/dist/host/ledger-read.js +64 -0
- package/dist/host/routes.d.ts +24 -0
- package/dist/host/routes.js +140 -0
- package/dist/host/script.d.ts +23 -0
- package/dist/host/script.js +59 -0
- package/dist/host/skill-registration.d.ts +27 -0
- package/dist/host/skill-registration.js +43 -0
- package/dist/host/types.d.ts +66 -0
- package/dist/host/types.js +7 -0
- package/dist/host/workspace.d.ts +13 -0
- package/dist/host/workspace.js +16 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +42 -0
- package/package.json +31 -6
- package/skill/README.md +251 -0
- package/skill/SKILL.md +176 -0
- package/skill/assets/worklog.md +31 -0
- package/skill/package.json +23 -0
- package/skill/scripts/progress.py +2012 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# pyright: standard
|
|
3
|
+
"""task-ledger — 管理项目任务进度。
|
|
4
|
+
|
|
5
|
+
ledger/progress.json 在办任务的状态
|
|
6
|
+
ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
|
|
7
|
+
单个任务的过程(路径另记在状态表 worklog 字段)
|
|
8
|
+
ledger/history/<年月>.md 已结案任务,一行一条(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档);
|
|
9
|
+
按结案时间分月归档。存量 ledger/PROGRESS-HISTORY.md 只读兼容、永不重写
|
|
10
|
+
|
|
11
|
+
只用标准库,无第三方依赖。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import contextlib
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import fcntl
|
|
33
|
+
except ImportError: # Windows
|
|
34
|
+
fcntl = None
|
|
35
|
+
try:
|
|
36
|
+
import msvcrt
|
|
37
|
+
except ImportError:
|
|
38
|
+
msvcrt = None
|
|
39
|
+
|
|
40
|
+
STATUSES = ("open", "doing", "blocked", "done")
|
|
41
|
+
PROGRESS_REL = Path("ledger/progress.json")
|
|
42
|
+
# 存量:改造前的单文件历史层,只读兼容、永不重写
|
|
43
|
+
HISTORY_REL = Path("ledger/PROGRESS-HISTORY.md")
|
|
44
|
+
HISTORY_DIR = Path(
|
|
45
|
+
"ledger/history"
|
|
46
|
+
) # 新历史层:按结案时间分月,ledger/history/<YYYYMM>.md
|
|
47
|
+
WORKLOG_DIR = Path("ledger/worklog")
|
|
48
|
+
HISTORY_TITLE = "# 进度历史(已结案)"
|
|
49
|
+
# 8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档。
|
|
50
|
+
# owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
|
|
51
|
+
# 分类/标签列(2026-10-15 存量改造时加入):close 从 task 直写;旧 4/5/6 列由
|
|
52
|
+
# upgrade_history 升级为 8 列(回链 worklog 头部补值),parse_history 三时代兼容读取。
|
|
53
|
+
HISTORY_HEADER = (
|
|
54
|
+
"| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | 完成了什么 | 过程文档 |"
|
|
55
|
+
)
|
|
56
|
+
HISTORY_SEP = "|---|---|---|---|---|---|---|---|"
|
|
57
|
+
FROZEN_MARK = "✅ 完成封卷"
|
|
58
|
+
ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
|
|
59
|
+
# 读侧兼容:秒可有可无(旧数据只到分)。写侧一律到秒,见 normalize_time。
|
|
60
|
+
TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
|
|
61
|
+
# 分类 / 标签口径:值由 Agent 在 new 时按 SKILL.md 规则自行总结(不设枚举),
|
|
62
|
+
# 脚本只硬校验形态 —— 含 CJK 按字符数 ≤4,纯 ASCII 按单词数 ≤3;标签数量只设上限,
|
|
63
|
+
# 设下限会逼 Agent 凑数,凑出来的正是噪声。
|
|
64
|
+
LABEL_MAX_CHARS = 4
|
|
65
|
+
LABEL_MAX_WORDS_EN = 3
|
|
66
|
+
TAGS_MAX_COUNT = 5
|
|
67
|
+
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
68
|
+
|
|
69
|
+
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
70
|
+
WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
|
|
71
|
+
|
|
72
|
+
FALLBACK_TEMPLATE = """# <id>:<title>
|
|
73
|
+
|
|
74
|
+
> **创建**:<created>(@<creator>)
|
|
75
|
+
|
|
76
|
+
## 背景
|
|
77
|
+
|
|
78
|
+
## 待办
|
|
79
|
+
|
|
80
|
+
## 下一步
|
|
81
|
+
|
|
82
|
+
## 过程
|
|
83
|
+
|
|
84
|
+
## 结论
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Fail(Exception):
|
|
89
|
+
"""业务错误:退出码非 0,只打印消息。"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------- 基础设施 ----------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def now() -> str:
|
|
96
|
+
"""当前时刻,精确到秒:YYYY-MM-DD HH:MM:SS。"""
|
|
97
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# 时间字面量:日期 + 可选「时:分[:秒]」。容忍 `/` 与 `T` 分隔(归一化后再匹配)。
|
|
101
|
+
_TIME_IN_RE = re.compile(
|
|
102
|
+
r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_time(
|
|
107
|
+
value: str | datetime,
|
|
108
|
+
*,
|
|
109
|
+
field: str = "时间",
|
|
110
|
+
tid: str | None = None,
|
|
111
|
+
date_only_ref: str | None = None,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""把「正确但不完整」的时间写法归一化到 YYYY-MM-DD HH:MM:SS。
|
|
114
|
+
|
|
115
|
+
接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
|
|
116
|
+
缺秒补 `:00`,缺分秒补 `00:00`(即 `00:00:00`)。非法值报错。
|
|
117
|
+
date_only_ref:只给了日期(没给时分)时,用这个时刻顶替(如 `--until` 想「含当天整天」传 `23:59:59`)。
|
|
118
|
+
"""
|
|
119
|
+
where = f"{tid}: " if tid else ""
|
|
120
|
+
if isinstance(value, datetime):
|
|
121
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
122
|
+
if not isinstance(value, str) or not value.strip():
|
|
123
|
+
raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
|
|
124
|
+
s = " ".join(
|
|
125
|
+
value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
|
|
126
|
+
)
|
|
127
|
+
m = _TIME_IN_RE.fullmatch(s)
|
|
128
|
+
if not m:
|
|
129
|
+
raise Fail(
|
|
130
|
+
f"{where}{field} 时间格式不对:{value!r}"
|
|
131
|
+
"(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
|
|
132
|
+
)
|
|
133
|
+
y, mo, d, hh, mm, ss = m.groups()
|
|
134
|
+
if date_only_ref is not None and hh is None:
|
|
135
|
+
out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
|
|
136
|
+
else:
|
|
137
|
+
out = (
|
|
138
|
+
f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
|
|
139
|
+
f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})")
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def time_key(value: object) -> str | None:
|
|
149
|
+
"""已存时间(可能只到分)→ 秒精度字符串,用于比较/排序;不合法返回 None。"""
|
|
150
|
+
if not isinstance(value, str) or not TIME_RE.fullmatch(value):
|
|
151
|
+
return None
|
|
152
|
+
return value if len(value) == 19 else value + ":00"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def find_repo(explicit: str | None) -> Path:
|
|
156
|
+
"""项目根:--repo > 向上找含 ledger/progress.json 的目录 > 向上找含 ledger/ 的目录。
|
|
157
|
+
|
|
158
|
+
git 根只作为一个候选,不无条件优先 —— 否则 monorepo 子目录会定位到整个仓库根。
|
|
159
|
+
"""
|
|
160
|
+
if explicit:
|
|
161
|
+
p = Path(explicit).expanduser().resolve()
|
|
162
|
+
if not p.is_dir():
|
|
163
|
+
raise Fail(f"--repo 不是目录:{p}")
|
|
164
|
+
return p
|
|
165
|
+
|
|
166
|
+
bases: list[Path] = []
|
|
167
|
+
try:
|
|
168
|
+
out = subprocess.run(
|
|
169
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
check=True,
|
|
173
|
+
).stdout.strip()
|
|
174
|
+
if out:
|
|
175
|
+
bases.append(Path(out).resolve())
|
|
176
|
+
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
here = Path.cwd().resolve()
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise Fail(f"拿不到当前目录:{exc}")
|
|
183
|
+
if here not in bases:
|
|
184
|
+
bases.append(here)
|
|
185
|
+
|
|
186
|
+
ordered: list[Path] = []
|
|
187
|
+
for base in bases:
|
|
188
|
+
for p in (base, *base.parents):
|
|
189
|
+
if p not in ordered:
|
|
190
|
+
ordered.append(p)
|
|
191
|
+
|
|
192
|
+
for p in ordered:
|
|
193
|
+
if (p / PROGRESS_REL).exists():
|
|
194
|
+
return p
|
|
195
|
+
for p in ordered:
|
|
196
|
+
if (p / "docs").is_dir():
|
|
197
|
+
return p
|
|
198
|
+
raise Fail(
|
|
199
|
+
"定位不到项目根:向上找不到 ledger/progress.json 或 ledger/。用 --repo 指定。"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def git_user() -> str:
|
|
204
|
+
"""owner 身份:只认 git config user.name。取不到就报错,不做任何兜底。"""
|
|
205
|
+
try:
|
|
206
|
+
proc = subprocess.run( # noqa: PLW1510 —— 要看 stdout,不抛异常
|
|
207
|
+
["git", "config", "user.name"], capture_output=True, text=True
|
|
208
|
+
)
|
|
209
|
+
except FileNotFoundError:
|
|
210
|
+
raise Fail("需要用户名才能执行:本机没有 git,读不到 git config user.name。")
|
|
211
|
+
name = proc.stdout.strip()
|
|
212
|
+
if not name:
|
|
213
|
+
raise Fail(
|
|
214
|
+
"需要用户名才能执行:读不到本机 git 身份(git config user.name 为空)。\n"
|
|
215
|
+
'先配置:git config --global user.name "你的名字"'
|
|
216
|
+
)
|
|
217
|
+
return name
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
221
|
+
if not path.exists():
|
|
222
|
+
return {"tasks": {}}
|
|
223
|
+
try:
|
|
224
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
225
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
226
|
+
raise Fail(f"读不了 {path}:{exc}")
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(text)
|
|
229
|
+
except json.JSONDecodeError as exc:
|
|
230
|
+
raise Fail(f"{path} 不是合法 JSON:{exc}")
|
|
231
|
+
if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
|
|
232
|
+
raise Fail(f'{path} 结构不对,顶层应为 {{"tasks": {{...}}}}')
|
|
233
|
+
return data
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def write_text_atomic(path: Path, text: str) -> None:
|
|
237
|
+
"""原子写文本:同目录唯一临时名 + fsync + os.replace,写完清理临时文件。
|
|
238
|
+
|
|
239
|
+
整段都在 try 内 —— mkdir / mkstemp 的 OSError 同样要转成 Fail,
|
|
240
|
+
否则会漏成裸异常,上层的「失败就撤销」也就收不到。
|
|
241
|
+
"""
|
|
242
|
+
fd = None
|
|
243
|
+
tmp = None
|
|
244
|
+
try:
|
|
245
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
247
|
+
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
|
248
|
+
)
|
|
249
|
+
tmp = Path(tmp_name)
|
|
250
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
251
|
+
fd = None # 所有权已交给文件对象
|
|
252
|
+
fh.write(text)
|
|
253
|
+
fh.flush()
|
|
254
|
+
os.fsync(fh.fileno())
|
|
255
|
+
os.replace(tmp, path)
|
|
256
|
+
tmp = None
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
raise Fail(f"写 {path} 失败:{exc}")
|
|
259
|
+
finally:
|
|
260
|
+
if fd is not None:
|
|
261
|
+
try:
|
|
262
|
+
os.close(fd)
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
if tmp is not None and tmp.exists():
|
|
266
|
+
try:
|
|
267
|
+
tmp.unlink()
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
273
|
+
"""原子写状态表。key 按字母序,减少并发新增时的文本冲突。"""
|
|
274
|
+
write_text_atomic(
|
|
275
|
+
path,
|
|
276
|
+
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def stat_key(path: Path) -> str | tuple[str, ...] | None:
|
|
281
|
+
"""文件内容的指纹;不存在返回 None。
|
|
282
|
+
|
|
283
|
+
刻意不用 (mtime, size):并发写入可能落在同一时间精度内、且字节数恰好相同,
|
|
284
|
+
那样指纹不变、写前复查就形同虚设(实测踩过)。用内容哈希,代价是多读一次小文件。
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
raw = path.read_bytes()
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
return None
|
|
290
|
+
except OSError:
|
|
291
|
+
return ("unreadable",)
|
|
292
|
+
return hashlib.sha256(raw).hexdigest()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@contextlib.contextmanager
|
|
296
|
+
def progress_lock(path: Path) -> Iterator[None]:
|
|
297
|
+
"""把状态表的「读-改-写」串行化,避免并发丢更新。
|
|
298
|
+
|
|
299
|
+
锁文件放系统临时目录(按项目绝对路径取名),不污染项目;进程退出时内核自动释放。
|
|
300
|
+
无 fcntl / msvcrt 的平台上退化为不加锁。
|
|
301
|
+
"""
|
|
302
|
+
if fcntl is None and msvcrt is None:
|
|
303
|
+
yield
|
|
304
|
+
return
|
|
305
|
+
key = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
|
|
306
|
+
lock_path = Path(tempfile.gettempdir()) / f"koc-progress-{key}.lock"
|
|
307
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
|
|
308
|
+
try:
|
|
309
|
+
if fcntl is not None:
|
|
310
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
311
|
+
elif msvcrt is not None:
|
|
312
|
+
# typeshed 把 msvcrt.locking / LK_* 门控在 win32,非 Windows 平台跑 pyright
|
|
313
|
+
# 会报「未知属性」;这段只有 Windows 真能执行到,故局部忽略(import 处是 try 容错)
|
|
314
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
315
|
+
yield
|
|
316
|
+
finally:
|
|
317
|
+
try:
|
|
318
|
+
if fcntl is not None:
|
|
319
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
320
|
+
elif msvcrt is not None:
|
|
321
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
322
|
+
finally:
|
|
323
|
+
os.close(fd)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def read_guarded(path: Path):
|
|
327
|
+
"""读状态表并记下指纹;配合 commit_guarded 做写前复查。"""
|
|
328
|
+
return stat_key(path), read_json(path)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def commit_guarded(
|
|
332
|
+
path: Path, before: str | tuple[str, ...] | None, data: dict[str, Any]
|
|
333
|
+
) -> None:
|
|
334
|
+
"""写前复查指纹:被并发改动过就报错,不覆盖别人的改动。"""
|
|
335
|
+
if stat_key(path) != before:
|
|
336
|
+
raise Fail(f"{path.name} 在操作期间被改动过,已中止。重新执行一次。")
|
|
337
|
+
write_json(path, data)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def require_task_id(tid: str) -> str:
|
|
341
|
+
"""任务号会参与拼路径,任何命令拿到它都要先验一遍。"""
|
|
342
|
+
if not ID_RE.fullmatch(tid):
|
|
343
|
+
raise Fail(
|
|
344
|
+
f"任务号 {tid!r} 不合规:只用小写字母、数字、连字符和汉字,且以字母、数字或汉字开头。"
|
|
345
|
+
)
|
|
346
|
+
return tid
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def normalize_label(value: str, *, field: str = "分类", tid: str | None = None) -> str:
|
|
350
|
+
"""分类 / 标签单值形态校验:拒控制字符与换行(防注入 worklog 头部行)、折叠内部空白。
|
|
351
|
+
|
|
352
|
+
含 CJK 按字符数 ≤4;纯 ASCII 按单词数 ≤3 —— 中英混排按字符数卡,逼 Agent 写短。
|
|
353
|
+
"""
|
|
354
|
+
where = f"{tid}: " if tid else ""
|
|
355
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
356
|
+
raise Fail(f"{where}{field}不能含控制字符或换行:{value!r}")
|
|
357
|
+
s = " ".join(value.split())
|
|
358
|
+
if not s:
|
|
359
|
+
raise Fail(f"{where}{field}不能为空。")
|
|
360
|
+
if CJK_RE.search(s):
|
|
361
|
+
if len(s) > LABEL_MAX_CHARS:
|
|
362
|
+
raise Fail(
|
|
363
|
+
f"{where}{field}「{s}」超长:含中文按字符数最多 {LABEL_MAX_CHARS} 个。"
|
|
364
|
+
)
|
|
365
|
+
elif len(s.split()) > LABEL_MAX_WORDS_EN:
|
|
366
|
+
raise Fail(
|
|
367
|
+
f"{where}{field}「{s}」超长:纯英文按单词数最多 {LABEL_MAX_WORDS_EN} 个。"
|
|
368
|
+
)
|
|
369
|
+
return s
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_tags(raw: str, *, field: str = "标签", tid: str | None = None) -> list[str]:
|
|
373
|
+
"""标签串 → 列表:逗号 / 顿号切分(**不按空格**——空格留给英文多词标签自身),
|
|
374
|
+
去空、去重保序;数量 ≤5,逐个过 normalize_label。"""
|
|
375
|
+
where = f"{tid}: " if tid else ""
|
|
376
|
+
tags: list[str] = []
|
|
377
|
+
for part in re.split(r"[,,、]", raw):
|
|
378
|
+
if not part.strip():
|
|
379
|
+
continue
|
|
380
|
+
tag = normalize_label(part, field=field, tid=tid)
|
|
381
|
+
if tag not in tags:
|
|
382
|
+
tags.append(tag)
|
|
383
|
+
if not tags:
|
|
384
|
+
raise Fail(f"{where}{field}不能为空。")
|
|
385
|
+
if len(tags) > TAGS_MAX_COUNT:
|
|
386
|
+
raise Fail(
|
|
387
|
+
f"{where}{field}最多 {TAGS_MAX_COUNT} 个,收到 {len(tags)} 个:{'、'.join(tags)}"
|
|
388
|
+
)
|
|
389
|
+
return tags
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def esc_cell(text: str) -> str:
|
|
393
|
+
"""表格单元格:折叠空白并转义竖线,避免把历史表格写坏。"""
|
|
394
|
+
return " ".join(text.split()).replace("|", "\\|")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def owner_cell(owner: str | None) -> str:
|
|
398
|
+
"""owner 列的展示值:人名前加 @(一眼看出这是个人),空给空串。
|
|
399
|
+
|
|
400
|
+
@ 只是展示层约定:存储(progress.json)与 --owner 匹配始终用裸名字,
|
|
401
|
+
parse_history 读回来时会把 @ 剥掉。
|
|
402
|
+
"""
|
|
403
|
+
return f"@{owner}" if owner else ""
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def history_line(
|
|
407
|
+
finished: str,
|
|
408
|
+
created: str | None,
|
|
409
|
+
title: str,
|
|
410
|
+
owner: str | None,
|
|
411
|
+
summary: str,
|
|
412
|
+
log_rel: str,
|
|
413
|
+
category: str | None = None,
|
|
414
|
+
tags: list[str] | None = None,
|
|
415
|
+
) -> str:
|
|
416
|
+
"""历史层的一行(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档)。
|
|
417
|
+
|
|
418
|
+
close 专用,避免两处格式漂移。owner 是独立列、单独过 esc_cell ——
|
|
419
|
+
它来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
|
|
420
|
+
"""
|
|
421
|
+
cat_cell = esc_cell(category or "")
|
|
422
|
+
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
423
|
+
return (
|
|
424
|
+
f"| {finished} | {created or ''} | {esc_cell(title)} | {cat_cell} | {tag_cell} "
|
|
425
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def history_line_for_cols(
|
|
430
|
+
cols: int,
|
|
431
|
+
finished: str,
|
|
432
|
+
created: str | None,
|
|
433
|
+
title: str,
|
|
434
|
+
owner: str | None,
|
|
435
|
+
summary: str,
|
|
436
|
+
log_rel: str,
|
|
437
|
+
category: str | None = None,
|
|
438
|
+
tags: list[str] | None = None,
|
|
439
|
+
) -> str:
|
|
440
|
+
"""「历史层有无法解析的行、不敢整体升级」时,按现有列数追加一行,避免列错位。
|
|
441
|
+
|
|
442
|
+
GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。cols<=4 ⇒ 旧 4 列(owner 缝在
|
|
443
|
+
工作包末尾);5 ⇒ 无创建时间列;6 ⇒ 无分类/标签列(存量改造前的最后形态);
|
|
444
|
+
否则 8 列。分类/标签只在 8 列里出现 —— 少列的文件硬塞会把整行挤歪。
|
|
445
|
+
"""
|
|
446
|
+
if cols <= 4:
|
|
447
|
+
who = f" @{owner}" if owner else ""
|
|
448
|
+
return (
|
|
449
|
+
f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} "
|
|
450
|
+
f"| [{log_rel}]({log_rel}) |\n"
|
|
451
|
+
)
|
|
452
|
+
if cols == 5:
|
|
453
|
+
return (
|
|
454
|
+
f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
|
|
455
|
+
f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
|
|
456
|
+
)
|
|
457
|
+
if cols == 6:
|
|
458
|
+
return (
|
|
459
|
+
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
460
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
|
|
461
|
+
)
|
|
462
|
+
return history_line(
|
|
463
|
+
finished, created, title, owner, summary, log_rel, category, tags
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def history_cols(text: str) -> int:
|
|
468
|
+
"""现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
|
|
469
|
+
counts: dict[int, int] = {}
|
|
470
|
+
for raw in text.splitlines():
|
|
471
|
+
if not raw.strip().startswith("|"):
|
|
472
|
+
continue
|
|
473
|
+
cells = split_row(raw)
|
|
474
|
+
if not cells or _is_sep_row(cells) or _looks_like_header(cells):
|
|
475
|
+
continue
|
|
476
|
+
n = len(cells)
|
|
477
|
+
if n >= 4:
|
|
478
|
+
counts[n] = counts.get(n, 0) + 1
|
|
479
|
+
if not counts:
|
|
480
|
+
return 6
|
|
481
|
+
return max(counts, key=lambda k: (counts[k], k))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# 旧行切 owner:最右一个「空格 + @」之后的整段是 owner(owner 可含空格,git user.name 合法)。
|
|
485
|
+
# 要求 @ 前有空白 ⇒ 标题里的 `rulemux@0.1.0` 不会误伤;贪婪 title 保证最后一个「 @」赢。
|
|
486
|
+
HISTORY_OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def split_row(line: str) -> list[str]:
|
|
490
|
+
"""按 | 切表格行。反顾后视让转义竖线 \\| 不当分隔符;切完再反转义、去首尾空单元。"""
|
|
491
|
+
cells = re.split(r"(?<!\\)\|", line.strip())
|
|
492
|
+
if cells and cells[0] == "":
|
|
493
|
+
cells = cells[1:]
|
|
494
|
+
if cells and cells[-1] == "":
|
|
495
|
+
cells = cells[:-1]
|
|
496
|
+
return [c.strip().replace("\\|", "|") for c in cells]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_sep_row(cells: list[str]) -> bool:
|
|
500
|
+
return bool(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _looks_like_header(cells: list[str]) -> bool:
|
|
504
|
+
# 按内容判、不按位置判:无表头的老文件首行就是数据,位置判断会把第一条记录当表头删掉。
|
|
505
|
+
# v6 表头首列是「结案时间」,「工作包」在第 3 列;v4/v5 首列是「时间」、「工作包」在第 2 列。
|
|
506
|
+
return bool(cells) and (cells[0] in ("时间", "结案时间") or "工作包" in cells[:3])
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
|
|
510
|
+
"""解析历史层,返回 (records, warnings)。
|
|
511
|
+
|
|
512
|
+
多格式兼容:8 列 = v8(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / …);6 列 = v6
|
|
513
|
+
(存量改造前最后形态,无分类/标签);5 列且第 2 列是时间 = 旧 ledger 格式
|
|
514
|
+
(有创建时间列、无 owner 列);5 列 = 无创建时间列;4 列 = 最旧(owner 缝在工作包末尾)。
|
|
515
|
+
**逐行按自己的列数判别**,表头只用来跳过 —— 旧文件追加新列
|
|
516
|
+
后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8、时间列不合法)不中断:跳过 + 警告。
|
|
517
|
+
"""
|
|
518
|
+
records: list[dict[str, Any]] = []
|
|
519
|
+
warnings: list[str] = []
|
|
520
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
521
|
+
if not raw.strip().startswith("|"):
|
|
522
|
+
continue
|
|
523
|
+
cells = split_row(raw)
|
|
524
|
+
if not cells or _is_sep_row(cells) or _looks_like_header(cells):
|
|
525
|
+
continue
|
|
526
|
+
created = ""
|
|
527
|
+
category: str | None = None
|
|
528
|
+
tags: list[str] = []
|
|
529
|
+
if len(cells) == 8:
|
|
530
|
+
finished, created, workpackage, category, tag_cell, owner, summary, doc = (
|
|
531
|
+
cells
|
|
532
|
+
)
|
|
533
|
+
fmt = "v8"
|
|
534
|
+
category = category.strip() or None
|
|
535
|
+
tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
|
|
536
|
+
elif len(cells) == 6:
|
|
537
|
+
finished, created, workpackage, owner, summary, doc = cells
|
|
538
|
+
fmt = "v6"
|
|
539
|
+
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
540
|
+
# 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
|
|
541
|
+
# 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
|
|
542
|
+
# v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
|
|
543
|
+
finished, created, workpackage, summary, doc = cells
|
|
544
|
+
owner = ""
|
|
545
|
+
fmt = "v5ledger"
|
|
546
|
+
elif len(cells) == 5:
|
|
547
|
+
finished, workpackage, owner, summary, doc = cells
|
|
548
|
+
fmt = "v5"
|
|
549
|
+
elif len(cells) == 4:
|
|
550
|
+
finished, workpackage, summary, doc = cells
|
|
551
|
+
m = HISTORY_OLD_OWNER_RE.match(workpackage)
|
|
552
|
+
if m:
|
|
553
|
+
workpackage, owner = m.group("title"), m.group("owner")
|
|
554
|
+
else:
|
|
555
|
+
owner = ""
|
|
556
|
+
fmt = "v4"
|
|
557
|
+
else:
|
|
558
|
+
warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8,已跳过")
|
|
559
|
+
continue
|
|
560
|
+
if not TIME_RE.fullmatch(finished):
|
|
561
|
+
warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
|
|
562
|
+
continue
|
|
563
|
+
m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", doc)
|
|
564
|
+
log = m.group(1) if m else doc
|
|
565
|
+
# 展示层写入带 @;存储与匹配一律裸名字
|
|
566
|
+
own = (owner or "").strip().removeprefix("@")
|
|
567
|
+
records.append(
|
|
568
|
+
{
|
|
569
|
+
"time": finished,
|
|
570
|
+
"created": time_key(created) if created else None,
|
|
571
|
+
"workpackage": workpackage,
|
|
572
|
+
"category": category,
|
|
573
|
+
"tags": tags,
|
|
574
|
+
"owner": own or None,
|
|
575
|
+
"summary": summary,
|
|
576
|
+
"log": log or None,
|
|
577
|
+
"fmt": fmt,
|
|
578
|
+
"line": lineno,
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
return records, warnings
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。用于给旧历史行回填创建时间。
|
|
585
|
+
WORKLOG_CREATED_RE = re.compile(
|
|
586
|
+
r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
|
|
587
|
+
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def created_for_history(log_cell: str, history_path: Path) -> str | None:
|
|
592
|
+
"""顺着历史行的文档链接找到 worklog,读它头部「创建」行的时间;找不到返回 None。
|
|
593
|
+
|
|
594
|
+
链接相对历史文件所在目录解析:存量平铺文件里是 `worklog/…`,按月文件里是 `../worklog/…`。
|
|
595
|
+
"""
|
|
596
|
+
m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
|
|
597
|
+
rel = (m.group(1) if m else log_cell).strip()
|
|
598
|
+
if not rel:
|
|
599
|
+
return None
|
|
600
|
+
try:
|
|
601
|
+
text = (history_path.parent / rel).read_text(encoding="utf-8")
|
|
602
|
+
except (OSError, UnicodeDecodeError):
|
|
603
|
+
return None
|
|
604
|
+
for ln in text.splitlines():
|
|
605
|
+
mm = WORKLOG_CREATED_RE.match(ln)
|
|
606
|
+
if mm:
|
|
607
|
+
try:
|
|
608
|
+
return normalize_time(mm.group(1), field="创建")
|
|
609
|
+
except Fail:
|
|
610
|
+
return None
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def labels_for_log(
|
|
615
|
+
log_cell: str | None, history_path: Path
|
|
616
|
+
) -> tuple[str | None, list[str]]:
|
|
617
|
+
"""顺着历史行的文档链接找 worklog,解析头部「分类/标签」行;读不到返回空。
|
|
618
|
+
|
|
619
|
+
接受纯路径或 markdown 链接单元格(`[x](y)`)—— cmd_history 传解好的路径,
|
|
620
|
+
upgrade_history 传原始单元格,两处都走这里。
|
|
621
|
+
"""
|
|
622
|
+
if not log_cell:
|
|
623
|
+
return (None, [])
|
|
624
|
+
m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
|
|
625
|
+
rel = (m.group(1) if m else log_cell).strip()
|
|
626
|
+
if not rel:
|
|
627
|
+
return (None, [])
|
|
628
|
+
try:
|
|
629
|
+
text = (history_path.parent / rel).read_text(encoding="utf-8")
|
|
630
|
+
except (OSError, UnicodeDecodeError):
|
|
631
|
+
return (None, [])
|
|
632
|
+
return parse_label_line(text)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
|
|
636
|
+
"""旧格式(4 / 5 / 6 列)→ v8(8 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
|
|
637
|
+
|
|
638
|
+
None 的语义是「不敢动」:调用方必须退回按原列数追加并明示,绝不半升级、绝不猜。
|
|
639
|
+
分类/标签列:回链每行的 worklog 头部读「分类/标签」行(读不到留空,不编造)——
|
|
640
|
+
与 created 的回填同一模式,依赖 Step 2 已给存量 worklog 补行。
|
|
641
|
+
缺创建时间的旧行:先读它链接的 worklog 头部「创建」行,找不到用升级时刻 —— 不允许留空。
|
|
642
|
+
5 列有两种(判别与 parse_history 一致,靠「第 2 列是不是时间」):旧 ledger 格式
|
|
643
|
+
(结案 / 创建 / 工作包 / 摘要 / 文档,无 owner)行内自带创建时间,优先用它;
|
|
644
|
+
v5(结案 / 工作包 / owner / 摘要 / 文档,无创建时间)才去 worklog 头找。
|
|
645
|
+
只重排表格行;标题、空行、表头外的散文原样保留。
|
|
646
|
+
history_path 只用于按文件所在目录解析文档链接。
|
|
647
|
+
"""
|
|
648
|
+
fallback = now()
|
|
649
|
+
out: list[str] = []
|
|
650
|
+
converted = 0
|
|
651
|
+
for raw in text.splitlines():
|
|
652
|
+
if not raw.strip().startswith("|"):
|
|
653
|
+
out.append(raw)
|
|
654
|
+
continue
|
|
655
|
+
cells = split_row(raw)
|
|
656
|
+
if not cells:
|
|
657
|
+
out.append(raw)
|
|
658
|
+
continue
|
|
659
|
+
if _is_sep_row(cells):
|
|
660
|
+
out.append(HISTORY_SEP)
|
|
661
|
+
continue
|
|
662
|
+
if _looks_like_header(cells):
|
|
663
|
+
out.append(HISTORY_HEADER)
|
|
664
|
+
continue
|
|
665
|
+
if len(cells) == 8:
|
|
666
|
+
# v8 行原样回写:内容已是最终格式(含分类/标签)
|
|
667
|
+
if time_key(cells[0]) is None:
|
|
668
|
+
return None
|
|
669
|
+
out.append("| " + " | ".join(cells) + " |")
|
|
670
|
+
continue
|
|
671
|
+
explicit_created: str | None = None
|
|
672
|
+
if len(cells) == 6:
|
|
673
|
+
finished, created, workpackage, owner, summary, doc = cells
|
|
674
|
+
# v6 升级:owner 单元格已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
|
|
675
|
+
owner = owner.lstrip("@")
|
|
676
|
+
explicit_created = time_key(created.strip())
|
|
677
|
+
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
678
|
+
# 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
|
|
679
|
+
# 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
|
|
680
|
+
# v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
|
|
681
|
+
finished, created_cell, workpackage, summary, doc = cells
|
|
682
|
+
owner = ""
|
|
683
|
+
explicit_created = time_key(created_cell.strip())
|
|
684
|
+
elif len(cells) == 5:
|
|
685
|
+
finished, workpackage, owner, summary, doc = cells
|
|
686
|
+
# v5 行 owner 列可能已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
|
|
687
|
+
owner = owner.lstrip("@")
|
|
688
|
+
elif len(cells) == 4:
|
|
689
|
+
finished, workpackage, summary, doc = cells
|
|
690
|
+
m = HISTORY_OLD_OWNER_RE.match(workpackage)
|
|
691
|
+
owner = m.group("owner") if m else ""
|
|
692
|
+
if m:
|
|
693
|
+
workpackage = m.group("title")
|
|
694
|
+
else:
|
|
695
|
+
return None
|
|
696
|
+
finished_key = time_key(finished)
|
|
697
|
+
if finished_key is None:
|
|
698
|
+
return None
|
|
699
|
+
created = explicit_created or created_for_history(doc, history_path) or fallback
|
|
700
|
+
cat, tags = labels_for_log(doc, history_path)
|
|
701
|
+
out.append(
|
|
702
|
+
f"| {finished_key} | {created} | {esc_cell(workpackage)} "
|
|
703
|
+
f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
|
|
704
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {doc} |"
|
|
705
|
+
)
|
|
706
|
+
converted += 1
|
|
707
|
+
return "\n".join(out) + "\n", converted
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ---------- 历史层:按结案时间分月归档 ----------
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def history_path_for(repo: Path, finished: str) -> Path:
|
|
714
|
+
"""结案时间(YYYY-MM-DD HH:MM:SS)→ ledger/history/<YYYYMM>.md。
|
|
715
|
+
|
|
716
|
+
与 worklog 的 ledger/worklog/<年月>/ 同构:一个按创建月、一个按结案月。
|
|
717
|
+
"""
|
|
718
|
+
month = re.sub(r"[^0-9]", "", finished[:7])[:6]
|
|
719
|
+
if len(month) != 6:
|
|
720
|
+
raise Fail(f"结案时间 {finished!r} 取不到年月,无法路由历史文件。")
|
|
721
|
+
return repo / HISTORY_DIR / f"{month}.md"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def history_sources(repo: Path) -> list[Path]:
|
|
725
|
+
"""全部历史来源(读侧用):存量平铺文件 + ledger/history/*.md,按路径排序。
|
|
726
|
+
|
|
727
|
+
存量文件只读 —— 写侧永远不碰它,它停在原地、链接基准(相对 ledger/)不变。
|
|
728
|
+
"""
|
|
729
|
+
paths: list[Path] = []
|
|
730
|
+
legacy = repo / HISTORY_REL
|
|
731
|
+
if legacy.exists():
|
|
732
|
+
paths.append(legacy)
|
|
733
|
+
hdir = repo / HISTORY_DIR
|
|
734
|
+
if hdir.is_dir():
|
|
735
|
+
paths.extend(sorted(p for p in hdir.glob("*.md") if p.is_file()))
|
|
736
|
+
return paths
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# 数行计数的封顶值(2026-10-17 为 dsh-task-ledger 看板统计卡新增):显示级统计封顶,
|
|
740
|
+
# 超出即 capped,精确值走 history --json / 列表。
|
|
741
|
+
COUNT_CAP = 10000
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def history_row_count(
|
|
745
|
+
sources: list[Path], since: str | None, until: str | None, cap: int = COUNT_CAP
|
|
746
|
+
) -> dict[str, Any]:
|
|
747
|
+
"""数行计数(只读;2026-10-17 新增,服务看板「本周 / 近一年完成」统计卡)。
|
|
748
|
+
|
|
749
|
+
只数数据行(`| 2` 开头 = 结案时间以年份开头),到 cap 封顶返回 capped=True。
|
|
750
|
+
给了 since/until 时按创建时间列(第 2 列)粗筛——取日期部分做字符串比较
|
|
751
|
+
(ISO 日期字典序即时间序),不建对象、不判格式,比 parse_history 便宜一个量级。
|
|
752
|
+
⚠️ 显示级统计:坏行 / 旧格式行可能多数少数,精确数以 history --json 为准。
|
|
753
|
+
"""
|
|
754
|
+
since_d = (since or "")[:10]
|
|
755
|
+
until_d = (until or "")[:10]
|
|
756
|
+
total = 0
|
|
757
|
+
for hp in sources:
|
|
758
|
+
try:
|
|
759
|
+
text = hp.read_text(encoding="utf-8-sig")
|
|
760
|
+
except (OSError, UnicodeDecodeError):
|
|
761
|
+
continue
|
|
762
|
+
for line in text.splitlines():
|
|
763
|
+
if not line.startswith("| 2"):
|
|
764
|
+
continue
|
|
765
|
+
if since_d or until_d:
|
|
766
|
+
cells = line.split("|")
|
|
767
|
+
if len(cells) < 4:
|
|
768
|
+
continue
|
|
769
|
+
created = cells[2].strip()[:10]
|
|
770
|
+
if since_d and created < since_d:
|
|
771
|
+
continue
|
|
772
|
+
if until_d and created > until_d:
|
|
773
|
+
continue
|
|
774
|
+
total += 1
|
|
775
|
+
if total >= cap:
|
|
776
|
+
return {"count": total, "capped": True}
|
|
777
|
+
return {"count": total, "capped": False}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def worklog_link(log_path: Path, history_path: Path) -> str:
|
|
781
|
+
"""worklog 相对历史文件所在目录的 posix 链接。
|
|
782
|
+
|
|
783
|
+
存量平铺文件得 `worklog/<月>/<文件>.md`;按月文件得 `../worklog/<月>/<文件>.md`
|
|
784
|
+
—— 链接跟着所在文件走,两边都可点击、可解析。
|
|
785
|
+
"""
|
|
786
|
+
return os.path.relpath(log_path, history_path.parent).replace(os.sep, "/")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def append_history_line(
|
|
790
|
+
history_path: Path,
|
|
791
|
+
finished: str,
|
|
792
|
+
created: str | None,
|
|
793
|
+
title: str,
|
|
794
|
+
owner: str | None,
|
|
795
|
+
summary: str,
|
|
796
|
+
log_rel: str,
|
|
797
|
+
category: str | None = None,
|
|
798
|
+
tags: list[str] | None = None,
|
|
799
|
+
) -> tuple[str, int]:
|
|
800
|
+
"""把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
|
|
801
|
+
|
|
802
|
+
读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 8 列写进少列表头)
|
|
803
|
+
→ 文件不存在则带 8 列表头新建。close 专用:「行落哪个月」之外,
|
|
804
|
+
历史行的落盘逻辑只此一处。分类/标签由 close 传 task 终值。
|
|
805
|
+
"""
|
|
806
|
+
if not history_path.exists():
|
|
807
|
+
return (
|
|
808
|
+
f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
|
|
809
|
+
+ history_line(
|
|
810
|
+
finished, created, title, owner, summary, log_rel, category, tags
|
|
811
|
+
),
|
|
812
|
+
0,
|
|
813
|
+
)
|
|
814
|
+
try:
|
|
815
|
+
text = history_path.read_text(encoding="utf-8")
|
|
816
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
817
|
+
raise Fail(f"读不了 {history_path}:{exc}")
|
|
818
|
+
if not text.endswith("\n"):
|
|
819
|
+
text += "\n"
|
|
820
|
+
upgraded = upgrade_history(text, history_path)
|
|
821
|
+
if upgraded is None:
|
|
822
|
+
# 有解析不了的行:不升级、不猜,退回「按现有列数」追加
|
|
823
|
+
print(
|
|
824
|
+
f"警告:{history_path.name} 有无法解析的行,未做格式升级,本次按现有列数追加。",
|
|
825
|
+
file=sys.stderr,
|
|
826
|
+
)
|
|
827
|
+
return (
|
|
828
|
+
text
|
|
829
|
+
+ history_line_for_cols(
|
|
830
|
+
history_cols(text),
|
|
831
|
+
finished,
|
|
832
|
+
created,
|
|
833
|
+
title,
|
|
834
|
+
owner,
|
|
835
|
+
summary,
|
|
836
|
+
log_rel,
|
|
837
|
+
category,
|
|
838
|
+
tags,
|
|
839
|
+
),
|
|
840
|
+
0,
|
|
841
|
+
)
|
|
842
|
+
new_text, n = upgraded
|
|
843
|
+
return (
|
|
844
|
+
new_text
|
|
845
|
+
+ history_line(
|
|
846
|
+
finished, created, title, owner, summary, log_rel, category, tags
|
|
847
|
+
),
|
|
848
|
+
n,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def slugify(title: str) -> str:
|
|
853
|
+
"""任务号用:保留 ASCII 字母数字与中文汉字,其余转连字符。"""
|
|
854
|
+
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.strip().lower())
|
|
855
|
+
return s.strip("-")[:40].strip("-")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def id_stamp(created: str) -> str:
|
|
859
|
+
"""从 created(YYYY-MM-DD HH:MM:SS)取任务号前缀 YYYYMMDD-HHMM(任务号只到分)。"""
|
|
860
|
+
return (
|
|
861
|
+
f"{created[0:4]}{created[5:7]}{created[8:10]}-{created[11:13]}{created[14:16]}"
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def make_id(title: str, tasks: dict[str, Any], created: str | None = None) -> str:
|
|
866
|
+
slug = slugify(title)
|
|
867
|
+
if not slug:
|
|
868
|
+
raise Fail(
|
|
869
|
+
"标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。"
|
|
870
|
+
f'用 --id 指定一个,例如 --id "task-{secrets.token_hex(2)}"。'
|
|
871
|
+
)
|
|
872
|
+
# 前置创建时间:年月日-时分(任务号 / 文件名只到分,与记录时间到秒分开)。随机码不能省
|
|
873
|
+
# —— make_id 只在在办任务里去重,而 worklog 结案后仍留在磁盘上,去掉随机码会让新任务与
|
|
874
|
+
# 已结案的过程文档同名、覆盖历史。
|
|
875
|
+
stamp = id_stamp(created) if created else datetime.now().strftime("%Y%m%d-%H%M")
|
|
876
|
+
for _ in range(20):
|
|
877
|
+
tid = f"{stamp}-{slug}-{secrets.token_hex(2)}"
|
|
878
|
+
if tid not in tasks:
|
|
879
|
+
return tid
|
|
880
|
+
raise Fail("任务号连续冲突 20 次,请检查状态表是否异常。")
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def load_task(repo: Path, tid: str) -> dict[str, Any]:
|
|
884
|
+
data = read_json(repo / PROGRESS_REL)
|
|
885
|
+
task = data["tasks"].get(tid)
|
|
886
|
+
if task is None:
|
|
887
|
+
raise Fail(f"没有任务 {tid}(可能已被结案或删除)。用 list 看现有任务。")
|
|
888
|
+
return task
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def require_owner(task: dict, tid: str, me: str) -> None:
|
|
892
|
+
"""只有当前 owner 能变更该任务;owner 取变更前的值,不放行就报错。"""
|
|
893
|
+
owner = task.get("owner")
|
|
894
|
+
if not owner:
|
|
895
|
+
status = task.get("status")
|
|
896
|
+
if status == "open":
|
|
897
|
+
raise Fail(f"任务 {tid} 还没人认领(open)。先认领再操作:claim --id {tid}")
|
|
898
|
+
raise Fail(
|
|
899
|
+
f"任务 {tid} 的 status 是 {status} 却没有 owner,状态表不一致。先跑 validate。"
|
|
900
|
+
)
|
|
901
|
+
if owner != me:
|
|
902
|
+
raise Fail(
|
|
903
|
+
f"任务 {tid} 的 owner 是 {owner},不是你({me})。\n"
|
|
904
|
+
f'要接手得让 {owner} 先交接:set --id {tid} --owner "{me}"'
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# 头部「分类/标签」行(label_line 生成、parse_label_line 解析,两处共用一个格式):
|
|
909
|
+
# `> **分类**:缺陷修复 | **标签**:镜像拉取、Hindsight`
|
|
910
|
+
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||]+)")
|
|
911
|
+
LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def label_line(category: str | None, tags: list[str] | None) -> str:
|
|
915
|
+
"""worklog 头部的「分类/标签」行;两者皆空返回空串(此行不写)。"""
|
|
916
|
+
segs = []
|
|
917
|
+
if category:
|
|
918
|
+
segs.append(f"**分类**:{category}")
|
|
919
|
+
if tags:
|
|
920
|
+
segs.append(f"**标签**:{'、'.join(tags)}")
|
|
921
|
+
return "> " + " | ".join(segs) if segs else ""
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _insert_after_head(lines: list[str], line: str) -> list[str]:
|
|
925
|
+
"""把一行插到「创建」行之后(头部引用块内);找不到创建行就插标题后。"""
|
|
926
|
+
at = next((i for i, ln in enumerate(lines) if WORKLOG_CREATED_RE.match(ln)), None)
|
|
927
|
+
if at is None:
|
|
928
|
+
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
929
|
+
lines.insert(at + 1, line)
|
|
930
|
+
return lines
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def parse_label_line(text: str) -> tuple[str | None, list[str]]:
|
|
934
|
+
"""从 worklog 头部(第一个 `## ` 小节之前)解析「分类/标签」行;解析不到返回空。"""
|
|
935
|
+
for ln in text.splitlines():
|
|
936
|
+
if ln.startswith("## "):
|
|
937
|
+
break
|
|
938
|
+
mc = LABEL_CAT_RE.search(ln)
|
|
939
|
+
mt = LABEL_TAG_RE.search(ln)
|
|
940
|
+
if not (mc or mt):
|
|
941
|
+
continue
|
|
942
|
+
cat = mc.group(1).strip() if mc else None
|
|
943
|
+
tags = (
|
|
944
|
+
[t.strip() for t in re.split(r"[,,、]", mt.group(1).strip()) if t.strip()]
|
|
945
|
+
if mt
|
|
946
|
+
else []
|
|
947
|
+
)
|
|
948
|
+
return (cat or None, tags)
|
|
949
|
+
return (None, [])
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def rewrite_label_line(text: str, category: str | None, tags: list[str]) -> str:
|
|
953
|
+
"""改写 worklog 头部的「分类/标签」行:有就地替换,没有就插到创建行后(close 覆盖用)。"""
|
|
954
|
+
line = label_line(category, tags)
|
|
955
|
+
lines = text.splitlines()
|
|
956
|
+
for i, ln in enumerate(lines):
|
|
957
|
+
if ln.startswith("## "):
|
|
958
|
+
break
|
|
959
|
+
if LABEL_CAT_RE.search(ln) or LABEL_TAG_RE.search(ln):
|
|
960
|
+
if line:
|
|
961
|
+
lines[i] = line
|
|
962
|
+
else:
|
|
963
|
+
del lines[i]
|
|
964
|
+
return "\n".join(lines) + "\n"
|
|
965
|
+
if not line:
|
|
966
|
+
return text
|
|
967
|
+
return "\n".join(_insert_after_head(lines, line)) + "\n"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def render_worklog(tid: str, task: dict[str, Any]) -> str:
|
|
971
|
+
template = FALLBACK_TEMPLATE
|
|
972
|
+
try:
|
|
973
|
+
if WORKLOG_TEMPLATE.exists():
|
|
974
|
+
template = WORKLOG_TEMPLATE.read_text(encoding="utf-8")
|
|
975
|
+
except (OSError, UnicodeDecodeError):
|
|
976
|
+
template = FALLBACK_TEMPLATE
|
|
977
|
+
text = (
|
|
978
|
+
template.replace("<id>", tid)
|
|
979
|
+
.replace("<title>", task.get("title") or "")
|
|
980
|
+
.replace("<created>", task.get("created") or "")
|
|
981
|
+
.replace("<creator>", task.get("creator") or "")
|
|
982
|
+
)
|
|
983
|
+
# 分类/标签行走动态插入(不占模板占位符):两个模板都不用改,空值时自动不写行
|
|
984
|
+
meta = label_line(task.get("category"), task.get("tags") or [])
|
|
985
|
+
if not meta:
|
|
986
|
+
return text
|
|
987
|
+
return "\n".join(_insert_after_head(text.splitlines(), meta)) + "\n"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def worklog_dir_for(task: dict[str, Any]) -> Path:
|
|
991
|
+
"""按月归档目录:ledger/worklog/<YYYYMM>/。取任务创建月份,取不到就退回当前月。"""
|
|
992
|
+
created = (task.get("created") or "").strip()
|
|
993
|
+
month = re.sub(r"[^0-9]", "", created[:7])[:6]
|
|
994
|
+
if len(month) != 6:
|
|
995
|
+
month = datetime.now().strftime("%Y%m")
|
|
996
|
+
return WORKLOG_DIR / month
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def worklog_name(tid: str) -> str:
|
|
1000
|
+
"""过程文档的文件名:任务号剥掉前缀的年月(YYYYMM)。
|
|
1001
|
+
|
|
1002
|
+
父目录 worklog/<YYYYMM>/ 已经带了年月,文件名里再写一遍是冗余,所以只留
|
|
1003
|
+
日-时分-说明-随机码:任务号 20261006-0000-doc-arch-review-7a3c(前 8 位
|
|
1004
|
+
是年月日)得到 06-0000-doc-arch-review-7a3c.md。随机码保留:结案文件长期
|
|
1005
|
+
留在磁盘上,同一分钟内建两个同标题任务仍要能不撞名。
|
|
1006
|
+
自定义 --id 不以「8 位数字-」开头时,文件名就是任务号本身。
|
|
1007
|
+
"""
|
|
1008
|
+
if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
|
|
1009
|
+
return f"{tid[6:]}.md"
|
|
1010
|
+
return f"{tid}.md"
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def worklog_rel(tid: str, task: dict[str, Any]) -> str:
|
|
1014
|
+
"""过程文档相对 ledger/ 的路径。状态表记它,历史层也记它,两处同口径。"""
|
|
1015
|
+
return (
|
|
1016
|
+
(worklog_dir_for(task) / worklog_name(tid))
|
|
1017
|
+
.relative_to(WORKLOG_DIR.parent)
|
|
1018
|
+
.as_posix()
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def find_worklog(
|
|
1023
|
+
repo: Path, tid: str, task: dict[str, Any] | None = None
|
|
1024
|
+
) -> Path | None:
|
|
1025
|
+
"""定位任务的过程文档。
|
|
1026
|
+
|
|
1027
|
+
先信状态表里记的路径(记了就不猜);没记再按新命名推算;最后回退到
|
|
1028
|
+
「文件名就是任务号」的老命名,并兼顾平铺旧位置 —— 规则改动前建的文件
|
|
1029
|
+
留在原地不动,照样能读能封卷。
|
|
1030
|
+
"""
|
|
1031
|
+
if task:
|
|
1032
|
+
recorded = task.get("worklog")
|
|
1033
|
+
if recorded:
|
|
1034
|
+
cand = repo / WORKLOG_DIR.parent / recorded
|
|
1035
|
+
if cand.exists():
|
|
1036
|
+
return cand
|
|
1037
|
+
parent = repo / WORKLOG_DIR
|
|
1038
|
+
names = (worklog_name(tid), f"{tid}.md")
|
|
1039
|
+
dirs = [parent]
|
|
1040
|
+
if parent.is_dir():
|
|
1041
|
+
dirs += [d for d in sorted(parent.iterdir()) if d.is_dir()]
|
|
1042
|
+
for d in dirs:
|
|
1043
|
+
for name in names:
|
|
1044
|
+
cand = d / name
|
|
1045
|
+
if cand.exists():
|
|
1046
|
+
return cand
|
|
1047
|
+
return None
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
|
|
1051
|
+
log = repo / worklog_dir_for(task) / worklog_name(tid)
|
|
1052
|
+
write_text_atomic(log, render_worklog(tid, task))
|
|
1053
|
+
return log
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def print_worklog(repo: Path, tid: str, task: dict[str, Any] | None = None) -> None:
|
|
1057
|
+
"""把该任务的过程文档全文打出来;读不到只提示,不拖垮主操作。"""
|
|
1058
|
+
log = find_worklog(repo, tid, task)
|
|
1059
|
+
if log is None:
|
|
1060
|
+
print(
|
|
1061
|
+
f"\n--- 缺少 {WORKLOG_DIR}/<年月>/{worklog_name(tid)}(跑 validate 看看) ---"
|
|
1062
|
+
)
|
|
1063
|
+
return
|
|
1064
|
+
try:
|
|
1065
|
+
body = log.read_text(encoding="utf-8").rstrip()
|
|
1066
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1067
|
+
print(f"\n--- 读不了 {log.relative_to(repo)}:{exc} ---", file=sys.stderr)
|
|
1068
|
+
return
|
|
1069
|
+
print(f"\n--- {log.relative_to(repo)} ---")
|
|
1070
|
+
print(body)
|
|
1071
|
+
print("--- 以上是该任务的过程文档,直接编辑这个文件即可 ---")
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def stamp_frozen(text: str, finished: str, finisher: str | None = "") -> str:
|
|
1075
|
+
"""把 worklog 标成已封卷:封卷标记并进头部引用块。"""
|
|
1076
|
+
lines = text.splitlines()
|
|
1077
|
+
who = f"(@{finisher})" if finisher else ""
|
|
1078
|
+
marker = f"> **{FROZEN_MARK}**:{finished}{who}"
|
|
1079
|
+
at = next((i for i, ln in enumerate(lines) if ln.startswith(">")), None)
|
|
1080
|
+
if at is None:
|
|
1081
|
+
# 只认一级标题;`## 背景` 这类小节标题不算,否则标记会插进正文里面。
|
|
1082
|
+
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
1083
|
+
lines.insert(at, marker)
|
|
1084
|
+
return "\n".join(lines) + "\n"
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
# ---------- 子命令 ----------
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
1091
|
+
repo = find_repo(args.repo)
|
|
1092
|
+
progress = repo / PROGRESS_REL
|
|
1093
|
+
with progress_lock(progress):
|
|
1094
|
+
if progress.exists():
|
|
1095
|
+
print(f"已存在,未改动:{progress.relative_to(repo)}")
|
|
1096
|
+
return
|
|
1097
|
+
before, data = read_guarded(progress)
|
|
1098
|
+
data.setdefault("tasks", {})
|
|
1099
|
+
commit_guarded(progress, before, data)
|
|
1100
|
+
print(f"已创建 {progress.relative_to(repo)}")
|
|
1101
|
+
print("按需创建的目录(worklog/、history/ 等)留到实际有内容时再建。")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def cmd_new(args: argparse.Namespace) -> None:
|
|
1105
|
+
repo = find_repo(args.repo)
|
|
1106
|
+
path = repo / PROGRESS_REL
|
|
1107
|
+
if not path.exists():
|
|
1108
|
+
raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
|
|
1109
|
+
creator = git_user()
|
|
1110
|
+
created = normalize_time(args.created, field="--created") if args.created else now()
|
|
1111
|
+
# 参数自检前移:分类/标签形态校验在锁外做,失败的调用不该占着锁
|
|
1112
|
+
category = (
|
|
1113
|
+
normalize_label(args.category, field="--category") if args.category else None
|
|
1114
|
+
)
|
|
1115
|
+
tags = parse_tags(args.tags, field="--tags") if args.tags else []
|
|
1116
|
+
|
|
1117
|
+
with progress_lock(path):
|
|
1118
|
+
before, data = read_guarded(path)
|
|
1119
|
+
tasks = data["tasks"]
|
|
1120
|
+
|
|
1121
|
+
tid = args.id or make_id(args.title, tasks, created)
|
|
1122
|
+
require_task_id(tid)
|
|
1123
|
+
if tid in tasks:
|
|
1124
|
+
raise Fail(f"任务号 {tid} 已存在。")
|
|
1125
|
+
|
|
1126
|
+
stamp = created
|
|
1127
|
+
task = {
|
|
1128
|
+
"title": args.title,
|
|
1129
|
+
"creator": creator,
|
|
1130
|
+
"owner": creator if args.self else None,
|
|
1131
|
+
"status": "doing" if args.self else "open",
|
|
1132
|
+
"created": stamp,
|
|
1133
|
+
"started": stamp if args.self else None,
|
|
1134
|
+
"finished": None,
|
|
1135
|
+
"category": category,
|
|
1136
|
+
"tags": tags,
|
|
1137
|
+
}
|
|
1138
|
+
# 路径先算好再落盘:文件名不等于任务号,状态表不记就只能靠约定去猜。
|
|
1139
|
+
task["worklog"] = worklog_rel(tid, task)
|
|
1140
|
+
tasks[tid] = task
|
|
1141
|
+
commit_guarded(path, before, data)
|
|
1142
|
+
try:
|
|
1143
|
+
log = write_worklog(repo, tid, task)
|
|
1144
|
+
except Fail as exc:
|
|
1145
|
+
# 过程文档建不出来就把刚写进去的任务撤掉,别留下「有任务、没文档」
|
|
1146
|
+
tasks.pop(tid, None)
|
|
1147
|
+
try:
|
|
1148
|
+
write_json(path, data)
|
|
1149
|
+
except Fail:
|
|
1150
|
+
pass
|
|
1151
|
+
raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
|
|
1152
|
+
print(f"已创建任务 {tid}")
|
|
1153
|
+
print(f" 标题 {task['title']}")
|
|
1154
|
+
print(f" 状态 {task['status']}")
|
|
1155
|
+
print(f" creator @{creator}")
|
|
1156
|
+
owner_show = f"@{task['owner']}" if task["owner"] else "(待认领)"
|
|
1157
|
+
print(f" owner {owner_show}")
|
|
1158
|
+
if category or tags:
|
|
1159
|
+
print(f" 分类 {category or '—'}")
|
|
1160
|
+
if tags:
|
|
1161
|
+
print(f" 标签 {'、'.join(tags)}")
|
|
1162
|
+
print(f" worklog {log.relative_to(repo)}")
|
|
1163
|
+
if not args.self:
|
|
1164
|
+
print(f"\n空开状态。谁做谁认领:progress.py claim --id {tid}")
|
|
1165
|
+
print_worklog(repo, tid, task)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def cmd_claim(args: argparse.Namespace) -> None:
|
|
1169
|
+
repo = find_repo(args.repo)
|
|
1170
|
+
tid = require_task_id(args.id)
|
|
1171
|
+
owner = git_user() # 先取身份:拿不到就直接报错,不进入后续判断
|
|
1172
|
+
path = repo / PROGRESS_REL
|
|
1173
|
+
with progress_lock(path):
|
|
1174
|
+
before, data = read_guarded(path)
|
|
1175
|
+
task = data["tasks"].get(tid)
|
|
1176
|
+
if task is None:
|
|
1177
|
+
raise Fail(f"没有任务 {tid}。")
|
|
1178
|
+
if task.get("owner"):
|
|
1179
|
+
raise Fail(
|
|
1180
|
+
f"任务 {tid} 状态不一致:owner 已是 {task['owner']},但 status 是 {task.get('status')}。先跑 validate。"
|
|
1181
|
+
)
|
|
1182
|
+
if task.get("status") != "open":
|
|
1183
|
+
raise Fail(
|
|
1184
|
+
f"任务 {tid} 当前状态是 {task.get('status')},只有 open 才能认领。"
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
task["owner"] = owner
|
|
1188
|
+
task["status"] = "doing"
|
|
1189
|
+
task["started"] = task.get("started") or now()
|
|
1190
|
+
commit_guarded(path, before, data)
|
|
1191
|
+
print(f"已认领 {tid} → doing,owner = @{owner}")
|
|
1192
|
+
print_worklog(repo, tid, task)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def cmd_set(args: argparse.Namespace) -> None:
|
|
1196
|
+
# 参数自检全部前移:必然失败的调用不该先去起 git 子进程、读文件
|
|
1197
|
+
if args.status is None and args.owner is None:
|
|
1198
|
+
raise Fail("至少要给 --status 或 --owner 之一。结案请用 close。")
|
|
1199
|
+
if args.status is not None and not args.status.strip():
|
|
1200
|
+
raise Fail(f"--status 不能为空;可选 {' / '.join(STATUSES)}")
|
|
1201
|
+
if args.owner is not None and not args.owner.strip():
|
|
1202
|
+
raise Fail("--owner 不能为空。")
|
|
1203
|
+
status = args.status.strip() if args.status else None
|
|
1204
|
+
if status == "open" and args.owner is not None:
|
|
1205
|
+
raise Fail(
|
|
1206
|
+
"--status open(退回未认领)不能同时给 --owner;要交给别人就别改回 open。"
|
|
1207
|
+
)
|
|
1208
|
+
if status is not None and status not in STATUSES:
|
|
1209
|
+
raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
|
|
1210
|
+
if status == "done":
|
|
1211
|
+
raise Fail(
|
|
1212
|
+
"不要把状态直接改成 done —— 用 close 结案(它会同时移出状态、追加历史、封卷 worklog)。"
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
repo = find_repo(args.repo)
|
|
1216
|
+
tid = require_task_id(args.id)
|
|
1217
|
+
me = git_user()
|
|
1218
|
+
path = repo / PROGRESS_REL
|
|
1219
|
+
with progress_lock(path):
|
|
1220
|
+
before, data = read_guarded(path)
|
|
1221
|
+
task = data["tasks"].get(tid)
|
|
1222
|
+
if task is None:
|
|
1223
|
+
raise Fail(f"没有任务 {tid}。")
|
|
1224
|
+
require_owner(task, tid, me)
|
|
1225
|
+
|
|
1226
|
+
changed = []
|
|
1227
|
+
if status is not None:
|
|
1228
|
+
task["status"] = status
|
|
1229
|
+
if status == "open":
|
|
1230
|
+
task["owner"] = None
|
|
1231
|
+
task["started"] = None
|
|
1232
|
+
changed.append(f"status={status}")
|
|
1233
|
+
|
|
1234
|
+
if args.owner is not None:
|
|
1235
|
+
old = task.get("owner")
|
|
1236
|
+
task["owner"] = args.owner.strip().lstrip(
|
|
1237
|
+
"@"
|
|
1238
|
+
) # 容忍「@名字」写法;存储与匹配用裸名
|
|
1239
|
+
if task.get("status") == "open":
|
|
1240
|
+
task["status"] = "doing"
|
|
1241
|
+
task["started"] = task.get("started") or now()
|
|
1242
|
+
changed.append(f"owner {old or '(无)'} → {task['owner']}")
|
|
1243
|
+
|
|
1244
|
+
commit_guarded(path, before, data)
|
|
1245
|
+
print(f"已更新 {tid}:" + ",".join(changed))
|
|
1246
|
+
print_worklog(repo, tid, task)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def cmd_close(args: argparse.Namespace) -> None:
|
|
1250
|
+
repo = find_repo(args.repo)
|
|
1251
|
+
tid = require_task_id(args.id)
|
|
1252
|
+
me = git_user()
|
|
1253
|
+
progress_path = repo / PROGRESS_REL
|
|
1254
|
+
# 参数自检前移:--category / --tags 的形态校验在锁外做(None = 没传,沿用建档值)
|
|
1255
|
+
close_category = (
|
|
1256
|
+
normalize_label(args.category, field="--category") if args.category else None
|
|
1257
|
+
)
|
|
1258
|
+
close_tags = parse_tags(args.tags, field="--tags") if args.tags else None
|
|
1259
|
+
|
|
1260
|
+
with progress_lock(progress_path):
|
|
1261
|
+
before, data = read_guarded(progress_path)
|
|
1262
|
+
task = data["tasks"].get(tid)
|
|
1263
|
+
if task is None:
|
|
1264
|
+
raise Fail(f"没有任务 {tid}(可能已结案)。")
|
|
1265
|
+
require_owner(task, tid, me)
|
|
1266
|
+
if task.get("finished"):
|
|
1267
|
+
raise Fail(f"任务 {tid} 已经结案过了。")
|
|
1268
|
+
|
|
1269
|
+
finished = (
|
|
1270
|
+
normalize_time(args.finished, field="--finished")
|
|
1271
|
+
if args.finished
|
|
1272
|
+
else now()
|
|
1273
|
+
)
|
|
1274
|
+
title = " ".join((task.get("title") or tid).split())
|
|
1275
|
+
summary = " ".join(args.summary.split())
|
|
1276
|
+
if not summary:
|
|
1277
|
+
raise Fail("--summary 不能为空:写清楚「做成了什么」。")
|
|
1278
|
+
|
|
1279
|
+
owner = task.get("owner") or me
|
|
1280
|
+
# 老文件留在平铺位置,新文件在按月子目录;找不到就按新规则推算目标路径。
|
|
1281
|
+
log_path = find_worklog(repo, tid, task) or (
|
|
1282
|
+
repo / worklog_dir_for(task) / worklog_name(tid)
|
|
1283
|
+
)
|
|
1284
|
+
# 历史行按结案时间落月文件;文档链接相对该文件所在目录算(../worklog/…)。
|
|
1285
|
+
history_path = history_path_for(repo, finished)
|
|
1286
|
+
log_rel = worklog_link(log_path, history_path)
|
|
1287
|
+
created_key = time_key(task.get("created")) or ""
|
|
1288
|
+
hist_before = stat_key(history_path)
|
|
1289
|
+
log_before = stat_key(log_path)
|
|
1290
|
+
|
|
1291
|
+
# 三个文件的新内容先在内存里备好
|
|
1292
|
+
new_tasks = dict(data["tasks"])
|
|
1293
|
+
new_tasks.pop(tid)
|
|
1294
|
+
|
|
1295
|
+
bak_path: Path | None = None
|
|
1296
|
+
# 历史行的分类/标签取终值:--category/--tags 覆盖过就用覆盖值,否则用建档值
|
|
1297
|
+
final_cat = (
|
|
1298
|
+
close_category if close_category is not None else task.get("category")
|
|
1299
|
+
)
|
|
1300
|
+
final_tags = close_tags if close_tags is not None else (task.get("tags") or [])
|
|
1301
|
+
new_history, history_upgrade_n = append_history_line(
|
|
1302
|
+
history_path,
|
|
1303
|
+
finished,
|
|
1304
|
+
created_key,
|
|
1305
|
+
title,
|
|
1306
|
+
owner,
|
|
1307
|
+
summary,
|
|
1308
|
+
log_rel,
|
|
1309
|
+
final_cat,
|
|
1310
|
+
final_tags,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
if log_path.exists():
|
|
1314
|
+
try:
|
|
1315
|
+
log_text = log_path.read_text(encoding="utf-8")
|
|
1316
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1317
|
+
raise Fail(f"读不了 {log_path}:{exc}")
|
|
1318
|
+
if close_category is not None or close_tags is not None:
|
|
1319
|
+
# 覆盖走改写头部「分类/标签」行(在封卷之前),真源与展示副本同口径
|
|
1320
|
+
log_text = rewrite_label_line(
|
|
1321
|
+
log_text,
|
|
1322
|
+
close_category
|
|
1323
|
+
if close_category is not None
|
|
1324
|
+
else task.get("category"),
|
|
1325
|
+
close_tags
|
|
1326
|
+
if close_tags is not None
|
|
1327
|
+
else list(task.get("tags") or []),
|
|
1328
|
+
)
|
|
1329
|
+
new_log = stamp_frozen(log_text, finished, owner)
|
|
1330
|
+
else:
|
|
1331
|
+
new_log = stamp_frozen(
|
|
1332
|
+
render_worklog(
|
|
1333
|
+
tid,
|
|
1334
|
+
dict(
|
|
1335
|
+
task,
|
|
1336
|
+
status="done",
|
|
1337
|
+
category=(
|
|
1338
|
+
close_category
|
|
1339
|
+
if close_category is not None
|
|
1340
|
+
else task.get("category")
|
|
1341
|
+
),
|
|
1342
|
+
tags=(
|
|
1343
|
+
close_tags
|
|
1344
|
+
if close_tags is not None
|
|
1345
|
+
else list(task.get("tags") or [])
|
|
1346
|
+
),
|
|
1347
|
+
),
|
|
1348
|
+
),
|
|
1349
|
+
finished,
|
|
1350
|
+
owner,
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
# 落盘前复查:三个文件都没被别人动过
|
|
1354
|
+
for p, mark in (
|
|
1355
|
+
(progress_path, before),
|
|
1356
|
+
(history_path, hist_before),
|
|
1357
|
+
(log_path, log_before),
|
|
1358
|
+
):
|
|
1359
|
+
if stat_key(p) != mark:
|
|
1360
|
+
raise Fail(
|
|
1361
|
+
f"{p.relative_to(repo)} 在操作期间被改动过,已中止。重新执行一次。"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
if history_upgrade_n:
|
|
1365
|
+
# 复查通过才落备份:并发中止时不留孤儿 .bak。成功后删除(内容没丢,只是 owner 换了列)。
|
|
1366
|
+
bak_path = history_path.with_name(history_path.name + ".pre-upgrade.bak")
|
|
1367
|
+
write_text_atomic(bak_path, history_path.read_text(encoding="utf-8"))
|
|
1368
|
+
print(
|
|
1369
|
+
f"历史层已升级为 8 列(补分类/标签,{history_upgrade_n} 行转换),原文件备份在 {bak_path.name}",
|
|
1370
|
+
file=sys.stderr,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
backups: dict[Path, bytes] = {}
|
|
1374
|
+
created: list[Path] = []
|
|
1375
|
+
for p in (progress_path, history_path, log_path):
|
|
1376
|
+
try:
|
|
1377
|
+
backups[p] = p.read_bytes()
|
|
1378
|
+
except FileNotFoundError:
|
|
1379
|
+
created.append(p)
|
|
1380
|
+
except OSError as exc:
|
|
1381
|
+
raise Fail(f"读不了 {p} 用于备份:{exc}")
|
|
1382
|
+
|
|
1383
|
+
# 顺序:先写历史与过程文档,**最后**才从状态表移除。
|
|
1384
|
+
# 中途崩溃最坏是「历史多一行、状态还留着」,可重跑识别,不会丢记录。
|
|
1385
|
+
try:
|
|
1386
|
+
write_text_atomic(history_path, new_history)
|
|
1387
|
+
write_text_atomic(log_path, new_log)
|
|
1388
|
+
write_json(
|
|
1389
|
+
progress_path, {"tasks": {k: v for k, v in sorted(new_tasks.items())}}
|
|
1390
|
+
)
|
|
1391
|
+
except (OSError, Fail) as exc:
|
|
1392
|
+
restored, failed = [], []
|
|
1393
|
+
history_restored = False
|
|
1394
|
+
for p, content in backups.items():
|
|
1395
|
+
try:
|
|
1396
|
+
p.write_bytes(content)
|
|
1397
|
+
restored.append(str(p.relative_to(repo)))
|
|
1398
|
+
if p == history_path:
|
|
1399
|
+
history_restored = True
|
|
1400
|
+
except OSError as rerr:
|
|
1401
|
+
failed.append(f"{p.relative_to(repo)}({rerr})")
|
|
1402
|
+
for p in created:
|
|
1403
|
+
try:
|
|
1404
|
+
if p.exists():
|
|
1405
|
+
p.unlink()
|
|
1406
|
+
except OSError as rerr:
|
|
1407
|
+
failed.append(f"{p.relative_to(repo)} 清理失败({rerr})")
|
|
1408
|
+
if bak_path is not None and bak_path.exists():
|
|
1409
|
+
if history_restored:
|
|
1410
|
+
try:
|
|
1411
|
+
bak_path.unlink()
|
|
1412
|
+
except OSError:
|
|
1413
|
+
pass
|
|
1414
|
+
else:
|
|
1415
|
+
# 历史层没恢复成功 ⇒ .bak 是最后保障,留着并报告
|
|
1416
|
+
failed.append(
|
|
1417
|
+
f"{bak_path.name}(历史层未恢复成功,升级备份保留待人工处理)"
|
|
1418
|
+
)
|
|
1419
|
+
msg = f"结案失败:{exc}"
|
|
1420
|
+
if restored:
|
|
1421
|
+
msg += f"\n已恢复:{'、'.join(restored)}"
|
|
1422
|
+
if failed:
|
|
1423
|
+
msg += f"\n回滚未完成,请手工检查:{'、'.join(failed)}"
|
|
1424
|
+
raise Fail(msg)
|
|
1425
|
+
|
|
1426
|
+
if bak_path is not None and bak_path.exists():
|
|
1427
|
+
try:
|
|
1428
|
+
bak_path.unlink()
|
|
1429
|
+
except OSError:
|
|
1430
|
+
pass
|
|
1431
|
+
|
|
1432
|
+
print(f"已结案 {tid}")
|
|
1433
|
+
print(f" 从 {PROGRESS_REL} 移出")
|
|
1434
|
+
print(f" 追加到 {history_path.relative_to(repo)}")
|
|
1435
|
+
print(f" 封卷 {log_path.relative_to(repo)}")
|
|
1436
|
+
if final_cat or final_tags:
|
|
1437
|
+
print(f" 分类 {final_cat or '—'}")
|
|
1438
|
+
if final_tags:
|
|
1439
|
+
print(f" 标签 {'、'.join(final_tags)}")
|
|
1440
|
+
print(f"\n摘要:{summary}")
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
1444
|
+
repo = find_repo(args.repo)
|
|
1445
|
+
path = repo / PROGRESS_REL
|
|
1446
|
+
if not path.exists():
|
|
1447
|
+
raise Fail(f"{PROGRESS_REL} 不存在:这个项目还没初始化,先跑 init。")
|
|
1448
|
+
data = read_json(path)
|
|
1449
|
+
|
|
1450
|
+
wanted = None
|
|
1451
|
+
if args.status is not None:
|
|
1452
|
+
wanted = [s.strip() for s in args.status.split(",") if s.strip()]
|
|
1453
|
+
if not wanted:
|
|
1454
|
+
raise Fail("--status 不能为空;逗号分隔,如 doing,blocked")
|
|
1455
|
+
bad = [s for s in wanted if s not in STATUSES]
|
|
1456
|
+
if bad:
|
|
1457
|
+
raise Fail(
|
|
1458
|
+
f"--status 含非法值 {', '.join(bad)};可选 {' / '.join(STATUSES)}"
|
|
1459
|
+
)
|
|
1460
|
+
if "done" in wanted:
|
|
1461
|
+
print(
|
|
1462
|
+
"提示:done 的任务已结案并移出状态表,用 history 子命令查"
|
|
1463
|
+
"(已结案在 ledger/history/<年月>.md,存量在 ledger/PROGRESS-HISTORY.md)。",
|
|
1464
|
+
file=sys.stderr,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
since = normalize_time(args.since, field="--since") if args.since else None
|
|
1468
|
+
until = (
|
|
1469
|
+
normalize_time(args.until, field="--until", date_only_ref="23:59:59")
|
|
1470
|
+
if args.until
|
|
1471
|
+
else None
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
me = git_user() if args.mine else None
|
|
1475
|
+
|
|
1476
|
+
rows = []
|
|
1477
|
+
for tid, task in sorted(data["tasks"].items()):
|
|
1478
|
+
if me is not None and task.get("owner") != me:
|
|
1479
|
+
continue
|
|
1480
|
+
if args.owner and task.get("owner") != args.owner.strip().lstrip("@"):
|
|
1481
|
+
continue
|
|
1482
|
+
if wanted is not None and task.get("status") not in wanted:
|
|
1483
|
+
continue
|
|
1484
|
+
# 分类精确匹配(统计轴,同值才能比较);标签包含匹配(容错下钻,吃掉同义变体)
|
|
1485
|
+
if args.category and (task.get("category") or "") != args.category.strip():
|
|
1486
|
+
continue
|
|
1487
|
+
if args.tag:
|
|
1488
|
+
q = args.tag.strip()
|
|
1489
|
+
if not any(q in t for t in (task.get("tags") or [])):
|
|
1490
|
+
continue
|
|
1491
|
+
created = time_key(task.get("created"))
|
|
1492
|
+
if since and (created is None or created < since):
|
|
1493
|
+
continue
|
|
1494
|
+
if until and (created is None or created > until):
|
|
1495
|
+
continue
|
|
1496
|
+
rows.append((tid, task))
|
|
1497
|
+
|
|
1498
|
+
if args.json:
|
|
1499
|
+
print(
|
|
1500
|
+
json.dumps({tid: task for tid, task in rows}, ensure_ascii=False, indent=2)
|
|
1501
|
+
)
|
|
1502
|
+
return
|
|
1503
|
+
|
|
1504
|
+
if not rows:
|
|
1505
|
+
print("没有符合条件的在办任务。")
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
print(f"在办任务 {len(rows)} 条:\n")
|
|
1509
|
+
for tid, task in rows:
|
|
1510
|
+
who = f"@{task['owner']}" if task.get("owner") else "(待认领)"
|
|
1511
|
+
print(f" [{task.get('status')}] {tid}")
|
|
1512
|
+
print(f" {task.get('title')} — owner={who},created={task.get('created')}")
|
|
1513
|
+
if task.get("category") or task.get("tags"):
|
|
1514
|
+
cat = task.get("category") or "—"
|
|
1515
|
+
line = f" 分类:{cat}"
|
|
1516
|
+
if task.get("tags"):
|
|
1517
|
+
line += f" | 标签:{'、'.join(task['tags'])}"
|
|
1518
|
+
print(line)
|
|
1519
|
+
print(
|
|
1520
|
+
"\n细节在 worklog 里(路径记在状态表 worklog 字段);已结案的用 history 子命令查"
|
|
1521
|
+
"(ledger/history/<年月>.md)。"
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def cmd_show(args: argparse.Namespace) -> None:
|
|
1526
|
+
repo = find_repo(args.repo)
|
|
1527
|
+
tid = require_task_id(args.id)
|
|
1528
|
+
task = load_task(repo, tid)
|
|
1529
|
+
print(json.dumps(task, ensure_ascii=False, indent=2))
|
|
1530
|
+
print_worklog(repo, tid, task)
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def cmd_tags(args: argparse.Namespace) -> None:
|
|
1534
|
+
"""分类/标签巡检与归并。
|
|
1535
|
+
|
|
1536
|
+
--all:全量频次表(扫全部 worklog 头部,每任务恰好一次;已结案也含在内)—— 让漂移可见。
|
|
1537
|
+
--rename 旧 新:把在办任务里等于「旧」的 category / tag 改成「新」,同步改在办 worklog
|
|
1538
|
+
头部;已封卷文档不动(封卷即不可变,看板侧自行归并)。
|
|
1539
|
+
"""
|
|
1540
|
+
if args.all and args.rename:
|
|
1541
|
+
raise Fail("--all 和 --rename 只能给一个。")
|
|
1542
|
+
if not args.all and not args.rename:
|
|
1543
|
+
raise Fail("至少给一个:--all(巡检)或 --rename 旧 新(归并)。")
|
|
1544
|
+
repo = find_repo(args.repo)
|
|
1545
|
+
|
|
1546
|
+
if args.rename:
|
|
1547
|
+
old, new = args.rename
|
|
1548
|
+
old = old.strip()
|
|
1549
|
+
new_label = normalize_label(new, field="新标签")
|
|
1550
|
+
path = repo / PROGRESS_REL
|
|
1551
|
+
with progress_lock(path):
|
|
1552
|
+
before, data = read_guarded(path)
|
|
1553
|
+
tasks = data["tasks"]
|
|
1554
|
+
changed: list[str] = []
|
|
1555
|
+
for tid, task in tasks.items():
|
|
1556
|
+
cat = task.get("category")
|
|
1557
|
+
tags = list(task.get("tags") or [])
|
|
1558
|
+
new_cat = new_label if cat == old else cat
|
|
1559
|
+
new_tags = [new_label if t == old else t for t in tags]
|
|
1560
|
+
if new_cat != cat or new_tags != tags:
|
|
1561
|
+
task["category"] = new_cat
|
|
1562
|
+
task["tags"] = new_tags
|
|
1563
|
+
changed.append(tid)
|
|
1564
|
+
if not changed:
|
|
1565
|
+
print(f"没有在办任务的分类/标签等于「{old}」。")
|
|
1566
|
+
return
|
|
1567
|
+
# 先改在办 worklog 头部(展示副本与真源同口径),最后才提交状态表:
|
|
1568
|
+
# 中途失败时 json 未动、worklog 改写幂等,重跑一次即可收敛
|
|
1569
|
+
backups: dict[Path, bytes] = {}
|
|
1570
|
+
try:
|
|
1571
|
+
for tid in changed:
|
|
1572
|
+
task = tasks[tid]
|
|
1573
|
+
log = find_worklog(repo, tid, task)
|
|
1574
|
+
if log is None:
|
|
1575
|
+
continue
|
|
1576
|
+
backups[log] = log.read_bytes()
|
|
1577
|
+
write_text_atomic(
|
|
1578
|
+
log,
|
|
1579
|
+
rewrite_label_line(
|
|
1580
|
+
backups[log].decode("utf-8"),
|
|
1581
|
+
task.get("category"),
|
|
1582
|
+
task.get("tags") or [],
|
|
1583
|
+
),
|
|
1584
|
+
)
|
|
1585
|
+
except (OSError, Fail, UnicodeDecodeError) as exc:
|
|
1586
|
+
for log, raw in backups.items():
|
|
1587
|
+
try:
|
|
1588
|
+
log.write_bytes(raw)
|
|
1589
|
+
except OSError:
|
|
1590
|
+
pass
|
|
1591
|
+
raise Fail(f"归并失败,已恢复已改文档,重跑一次即可:{exc}")
|
|
1592
|
+
commit_guarded(path, before, data)
|
|
1593
|
+
print(f"已归并「{old}」→「{new_label}」,涉及 {len(changed)} 个在办任务:")
|
|
1594
|
+
for tid in changed:
|
|
1595
|
+
print(f" {tid}")
|
|
1596
|
+
return
|
|
1597
|
+
|
|
1598
|
+
# --all:worklog 目录是唯一来源 —— 在办任务的 worklog 也在里面(new 建档即写),
|
|
1599
|
+
# 与状态表合并会重复计数;已结案任务状态表已移除、只剩 worklog,天然只计一次。
|
|
1600
|
+
cat_count: dict[str, int] = {}
|
|
1601
|
+
tag_count: dict[str, int] = {}
|
|
1602
|
+
wdir = repo / WORKLOG_DIR
|
|
1603
|
+
n_logs = 0
|
|
1604
|
+
if wdir.is_dir():
|
|
1605
|
+
for log in sorted(wdir.rglob("*.md")):
|
|
1606
|
+
try:
|
|
1607
|
+
text = log.read_text(encoding="utf-8")
|
|
1608
|
+
except (OSError, UnicodeDecodeError):
|
|
1609
|
+
continue
|
|
1610
|
+
cat, tags = parse_label_line(text)
|
|
1611
|
+
n_logs += 1
|
|
1612
|
+
if cat:
|
|
1613
|
+
cat_count[cat] = cat_count.get(cat, 0) + 1
|
|
1614
|
+
for t in tags:
|
|
1615
|
+
tag_count[t] = tag_count.get(t, 0) + 1
|
|
1616
|
+
if not cat_count and not tag_count:
|
|
1617
|
+
print("还没有任何分类/标签:new 时用 --category / --tags 让 Agent 自行总结。")
|
|
1618
|
+
return
|
|
1619
|
+
print(f"扫描 {n_logs} 份 worklog:\n")
|
|
1620
|
+
print("分类(看板统计轴,精确匹配):")
|
|
1621
|
+
for cat, n in sorted(cat_count.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
1622
|
+
print(f" {n:>4} {cat}")
|
|
1623
|
+
print("\n标签(下钻用,包含匹配):")
|
|
1624
|
+
for t, n in sorted(tag_count.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
1625
|
+
print(f" {n:>4} {t}")
|
|
1626
|
+
low = sorted(t for t, n in tag_count.items() if n <= 2)
|
|
1627
|
+
if low:
|
|
1628
|
+
print(
|
|
1629
|
+
f"\n低频标签(≤2 次,疑似变体):{'、'.join(low)}\n"
|
|
1630
|
+
"归并:tags --rename <旧> <新>(只改在办任务;已封卷文档不动)"
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def cmd_validate(args: argparse.Namespace) -> None:
|
|
1635
|
+
repo = find_repo(args.repo)
|
|
1636
|
+
progress_path = repo / PROGRESS_REL
|
|
1637
|
+
problems: list[str] = []
|
|
1638
|
+
|
|
1639
|
+
if not progress_path.exists():
|
|
1640
|
+
raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
|
|
1641
|
+
|
|
1642
|
+
data = read_json(progress_path)
|
|
1643
|
+
for tid, task in sorted(data["tasks"].items()):
|
|
1644
|
+
if not ID_RE.fullmatch(tid):
|
|
1645
|
+
problems.append(f"{tid}: 任务号不合规(小写字母、数字、连字符、汉字)")
|
|
1646
|
+
if not task.get("title"):
|
|
1647
|
+
problems.append(f"{tid}: 缺 title")
|
|
1648
|
+
status = task.get("status")
|
|
1649
|
+
if status not in STATUSES:
|
|
1650
|
+
problems.append(f"{tid}: status={status!r} 非法")
|
|
1651
|
+
if status == "open" and task.get("owner"):
|
|
1652
|
+
problems.append(f"{tid}: status=open 却有 owner")
|
|
1653
|
+
if status in ("doing", "blocked") and not task.get("owner"):
|
|
1654
|
+
problems.append(f"{tid}: status={status} 却没有 owner")
|
|
1655
|
+
if status == "done":
|
|
1656
|
+
problems.append(f"{tid}: status=done 不该留在状态表(该用 close 结案)")
|
|
1657
|
+
cat = task.get("category")
|
|
1658
|
+
if cat is not None:
|
|
1659
|
+
try:
|
|
1660
|
+
normalize_label(cat, tid=tid)
|
|
1661
|
+
except Fail as exc:
|
|
1662
|
+
problems.append(str(exc))
|
|
1663
|
+
raw_tags = task.get("tags")
|
|
1664
|
+
if raw_tags is not None:
|
|
1665
|
+
if not isinstance(raw_tags, list) or any(
|
|
1666
|
+
not isinstance(t, str) for t in raw_tags
|
|
1667
|
+
):
|
|
1668
|
+
problems.append(f"{tid}: tags 必须是字符串数组")
|
|
1669
|
+
else:
|
|
1670
|
+
for t in raw_tags:
|
|
1671
|
+
try:
|
|
1672
|
+
normalize_label(t, field="标签", tid=tid)
|
|
1673
|
+
except Fail as exc:
|
|
1674
|
+
problems.append(str(exc))
|
|
1675
|
+
if len(raw_tags) > TAGS_MAX_COUNT:
|
|
1676
|
+
problems.append(f"{tid}: tags 超过 {TAGS_MAX_COUNT} 个")
|
|
1677
|
+
if find_worklog(repo, tid, task) is None:
|
|
1678
|
+
problems.append(
|
|
1679
|
+
f"{tid}: 缺 worklog 文件(状态表记的路径:{task.get('worklog') or '未记'})"
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
if problems:
|
|
1683
|
+
for p in problems:
|
|
1684
|
+
print(f"✗ {p}", file=sys.stderr)
|
|
1685
|
+
raise Fail(f"校验失败:{len(problems)} 项问题。")
|
|
1686
|
+
|
|
1687
|
+
print(f"✓ 校验通过(在办 {len(data['tasks'])} 条)")
|
|
1688
|
+
|
|
1689
|
+
# 历史层只读体检:发现非 v6 只提示、不写盘 —— 「要不要升级」交给下一次真实写入,
|
|
1690
|
+
# validate 保持只读契约(原设计就不碰历史层,这里只补一条可见的迁移信号)。
|
|
1691
|
+
# 存量平铺文件是只读来源,不会被写入,故其中的旧格式行不会被自动升级。
|
|
1692
|
+
for hp in history_sources(repo):
|
|
1693
|
+
rel = hp.relative_to(repo).as_posix()
|
|
1694
|
+
try:
|
|
1695
|
+
htext = hp.read_text(encoding="utf-8-sig")
|
|
1696
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1697
|
+
print(f"提示:读不了 {rel}:{exc}", file=sys.stderr)
|
|
1698
|
+
continue
|
|
1699
|
+
hrecords, hwarnings = parse_history(htext)
|
|
1700
|
+
old_n = sum(1 for r in hrecords if r["fmt"] != "v8")
|
|
1701
|
+
if old_n:
|
|
1702
|
+
print(
|
|
1703
|
+
f"提示:{rel} 含 {old_n} 行旧格式(4 / 5 / 6 列);"
|
|
1704
|
+
"只读文件不自动升级,新记录一律按 8 列写入 ledger/history/。",
|
|
1705
|
+
file=sys.stderr,
|
|
1706
|
+
)
|
|
1707
|
+
for w in hwarnings:
|
|
1708
|
+
print(f"提示:{rel} {w}", file=sys.stderr)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def cmd_history(args: argparse.Namespace) -> None:
|
|
1712
|
+
"""查已结案任务(只读)。不加锁、不写任何字节、不依赖格式升级是否完成。
|
|
1713
|
+
|
|
1714
|
+
--owner / --mine 一律**精确相等**:身份标识没有「按前缀找人」的语义,
|
|
1715
|
+
子串匹配正是 a 误命中 a-11 的根因。
|
|
1716
|
+
--since / --until 按**创建时间**过滤(含边界),可只给其一;旧格式行没有创建时间,
|
|
1717
|
+
带时间过滤时会被排除(另行提示)。--limit 在过滤 + 倒序之后取前 N 条。
|
|
1718
|
+
默认输出紧凑单行、不含「完成了什么」摘要(与 list 不吐正文同一原则);要全量走 --json。
|
|
1719
|
+
"""
|
|
1720
|
+
if args.owner is not None and args.mine:
|
|
1721
|
+
raise Fail("--owner 和 --mine 只能给一个:要么查某人,要么查自己。")
|
|
1722
|
+
if args.owner is not None and not args.owner.strip():
|
|
1723
|
+
raise Fail("--owner 不能为空。")
|
|
1724
|
+
if args.limit is not None and args.limit < 1:
|
|
1725
|
+
raise Fail("--limit 必须是正整数。")
|
|
1726
|
+
since = normalize_time(args.since, field="--since") if args.since else None
|
|
1727
|
+
until = (
|
|
1728
|
+
normalize_time(args.until, field="--until", date_only_ref="23:59:59")
|
|
1729
|
+
if args.until
|
|
1730
|
+
else None
|
|
1731
|
+
)
|
|
1732
|
+
|
|
1733
|
+
repo = find_repo(args.repo)
|
|
1734
|
+
sources = history_sources(repo)
|
|
1735
|
+
if not sources:
|
|
1736
|
+
print(
|
|
1737
|
+
f"没有已结案记录:ledger/history/ 还没有文件(存量 {HISTORY_REL} 也没有)。"
|
|
1738
|
+
)
|
|
1739
|
+
return
|
|
1740
|
+
if args.months is not None:
|
|
1741
|
+
if args.months < 1:
|
|
1742
|
+
raise Fail("--months 必须是正整数")
|
|
1743
|
+
# 只取按月归档的文件(存量平铺文件不进了——它是改造前的历史,窗口模式用不到)
|
|
1744
|
+
monthly = [p for p in sources if p.parent.name == HISTORY_DIR.name]
|
|
1745
|
+
sources = monthly[-args.months :]
|
|
1746
|
+
if args.count_only:
|
|
1747
|
+
print(json.dumps(history_row_count(sources, since, until), ensure_ascii=False))
|
|
1748
|
+
return
|
|
1749
|
+
records: list[dict[str, Any]] = []
|
|
1750
|
+
warnings: list[str] = []
|
|
1751
|
+
for hp in sources:
|
|
1752
|
+
rel = hp.relative_to(repo).as_posix()
|
|
1753
|
+
try:
|
|
1754
|
+
text = hp.read_text(encoding="utf-8-sig")
|
|
1755
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1756
|
+
raise Fail(f"读不了 {hp}:{exc}")
|
|
1757
|
+
recs, warns = parse_history(text)
|
|
1758
|
+
for r in recs:
|
|
1759
|
+
r["src"] = rel # 行所在的历史文件:链接相对它所在目录,带上才可定位
|
|
1760
|
+
# 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
|
|
1761
|
+
if not r.get("category") and not r.get("tags"):
|
|
1762
|
+
r["category"], r["tags"] = labels_for_log(r["log"], hp)
|
|
1763
|
+
records.extend(recs)
|
|
1764
|
+
warnings.extend(f"{rel} {w}" for w in warns)
|
|
1765
|
+
|
|
1766
|
+
me = git_user() if args.mine else None
|
|
1767
|
+
want_owner = (
|
|
1768
|
+
args.owner.strip().lstrip("@") if args.owner else None
|
|
1769
|
+
) # 容忍「@名字」写法
|
|
1770
|
+
rows = []
|
|
1771
|
+
undated = 0
|
|
1772
|
+
for r in records:
|
|
1773
|
+
if want_owner is not None and r["owner"] != want_owner:
|
|
1774
|
+
continue
|
|
1775
|
+
if me is not None and r["owner"] != me:
|
|
1776
|
+
continue
|
|
1777
|
+
# 分类精确、标签包含(与 list 同口径;值优先读列,列空回链 worklog 头部)
|
|
1778
|
+
if args.category and r.get("category") != args.category.strip():
|
|
1779
|
+
continue
|
|
1780
|
+
if args.tag:
|
|
1781
|
+
q = args.tag.strip()
|
|
1782
|
+
if not any(q in t for t in r.get("tags") or []):
|
|
1783
|
+
continue
|
|
1784
|
+
if since or until:
|
|
1785
|
+
c = r.get("created")
|
|
1786
|
+
if c is None: # 旧格式行没有创建时间,无法参与时间过滤
|
|
1787
|
+
undated += 1
|
|
1788
|
+
continue
|
|
1789
|
+
if since and c < since:
|
|
1790
|
+
continue
|
|
1791
|
+
if until and c > until:
|
|
1792
|
+
continue
|
|
1793
|
+
rows.append(r)
|
|
1794
|
+
if undated:
|
|
1795
|
+
print(
|
|
1796
|
+
f"提示:{undated} 行没有创建时间(旧格式),未参与时间过滤;"
|
|
1797
|
+
"下次 close 写入后会自动补齐。",
|
|
1798
|
+
file=sys.stderr,
|
|
1799
|
+
)
|
|
1800
|
+
# 倒序:最新创建在前(旧行无创建时间时退回按结案时间);同一时刻稳定排序保持文件物理先后。
|
|
1801
|
+
rows.sort(key=lambda r: r.get("created") or r["time"], reverse=True)
|
|
1802
|
+
if args.limit is not None:
|
|
1803
|
+
rows = rows[: args.limit]
|
|
1804
|
+
|
|
1805
|
+
if args.json:
|
|
1806
|
+
print(
|
|
1807
|
+
json.dumps(
|
|
1808
|
+
{
|
|
1809
|
+
"count": len(rows),
|
|
1810
|
+
"filters": {
|
|
1811
|
+
"owner": want_owner,
|
|
1812
|
+
"mine": args.mine,
|
|
1813
|
+
"since": since,
|
|
1814
|
+
"until": until,
|
|
1815
|
+
"limit": args.limit,
|
|
1816
|
+
"category": args.category.strip() if args.category else None,
|
|
1817
|
+
"tag": args.tag.strip() if args.tag else None,
|
|
1818
|
+
},
|
|
1819
|
+
"records": rows,
|
|
1820
|
+
},
|
|
1821
|
+
ensure_ascii=False,
|
|
1822
|
+
indent=2,
|
|
1823
|
+
)
|
|
1824
|
+
)
|
|
1825
|
+
return
|
|
1826
|
+
|
|
1827
|
+
if not rows:
|
|
1828
|
+
print("没有符合条件的已结案记录。")
|
|
1829
|
+
return
|
|
1830
|
+
who_label = want_owner or me
|
|
1831
|
+
label = f"(owner=@{who_label})" if who_label else ""
|
|
1832
|
+
span = f",创建 {since or '…'} ~ {until or '…'}" if (since or until) else ""
|
|
1833
|
+
print(f"已结案 {len(rows)} 条{label}{span}:\n")
|
|
1834
|
+
|
|
1835
|
+
def _who(r: dict[str, Any]) -> str:
|
|
1836
|
+
return f"@{r['owner']}" if r["owner"] else "(无 owner)"
|
|
1837
|
+
|
|
1838
|
+
width = max(len(_who(r)) for r in rows)
|
|
1839
|
+
for r in rows:
|
|
1840
|
+
who = _who(r).ljust(width)
|
|
1841
|
+
print(
|
|
1842
|
+
f" {r['time']} 建 {r.get('created') or '—'} {who} "
|
|
1843
|
+
f"[{r.get('category') or '—'}] "
|
|
1844
|
+
f"{r['workpackage']} → {r['log'] or '(无过程文档)'}({r.get('src', '')})"
|
|
1845
|
+
)
|
|
1846
|
+
if warnings:
|
|
1847
|
+
print(f"\n({len(warnings)} 行无法解析,已跳过;详见 stderr)")
|
|
1848
|
+
print(
|
|
1849
|
+
"\n时间列=结案时间;过滤按创建时间。默认不输出「完成了什么」;要摘要跑 history --json。"
|
|
1850
|
+
"\n方括号=分类(回链自 worklog 头部;历史行本身不存这列)。"
|
|
1851
|
+
"\n行尾括号=该行所在的历史文件(文档链接相对它所在目录解析)。"
|
|
1852
|
+
)
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
# ---------- 入口 ----------
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1859
|
+
parser = argparse.ArgumentParser(
|
|
1860
|
+
prog="progress.py",
|
|
1861
|
+
description="管理项目任务进度(ledger/progress.json + worklog + 历史)",
|
|
1862
|
+
)
|
|
1863
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1864
|
+
|
|
1865
|
+
def add_repo(p: argparse.ArgumentParser) -> None:
|
|
1866
|
+
p.add_argument("--repo", help="当前工作区(项目根)。agent 调用时应显式传入")
|
|
1867
|
+
|
|
1868
|
+
p = sub.add_parser("init", help="建 ledger/progress.json")
|
|
1869
|
+
add_repo(p)
|
|
1870
|
+
p.set_defaults(func=cmd_init)
|
|
1871
|
+
|
|
1872
|
+
p = sub.add_parser("new", help="新建任务")
|
|
1873
|
+
p.add_argument("--title", required=True, help="一句话标题")
|
|
1874
|
+
p.add_argument("--id", help="指定任务号,默认取标题生成的语义名 + 4 位随机")
|
|
1875
|
+
p.add_argument(
|
|
1876
|
+
"--created", help="补录创建时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
|
|
1877
|
+
)
|
|
1878
|
+
p.add_argument(
|
|
1879
|
+
"--self",
|
|
1880
|
+
action="store_true",
|
|
1881
|
+
help="创建即由自己执行(doing),否则留 open 待认领",
|
|
1882
|
+
)
|
|
1883
|
+
p.add_argument(
|
|
1884
|
+
"--category",
|
|
1885
|
+
help="一句话分类:这事是什么类型(≤4 字;纯英文 ≤3 词)。由 Agent 按口径总结,可省",
|
|
1886
|
+
)
|
|
1887
|
+
p.add_argument(
|
|
1888
|
+
"--tags",
|
|
1889
|
+
help="标签:这事涉及什么(对象/领域/模块),逗号分隔(至多 5 个,每个 ≤4 字;空格归英文多词标签自身,不当分隔符)。由 Agent 按口径总结,可省",
|
|
1890
|
+
)
|
|
1891
|
+
add_repo(p)
|
|
1892
|
+
p.set_defaults(func=cmd_new)
|
|
1893
|
+
|
|
1894
|
+
p = sub.add_parser("claim", help="认领一个 open 任务(认领人只能是当前 git 身份)")
|
|
1895
|
+
p.add_argument("--id", required=True)
|
|
1896
|
+
add_repo(p)
|
|
1897
|
+
p.set_defaults(func=cmd_claim)
|
|
1898
|
+
|
|
1899
|
+
p = sub.add_parser("set", help="改状态 / 交接(结案请用 close)")
|
|
1900
|
+
p.add_argument("--id", required=True)
|
|
1901
|
+
p.add_argument("--status", help=f"{' / '.join(STATUSES)}(done 请走 close)")
|
|
1902
|
+
p.add_argument("--owner", help="交接给谁")
|
|
1903
|
+
add_repo(p)
|
|
1904
|
+
p.set_defaults(func=cmd_set)
|
|
1905
|
+
|
|
1906
|
+
p = sub.add_parser("close", help="结案:移出状态 + 追加历史 + 封卷 worklog")
|
|
1907
|
+
p.add_argument("--id", required=True)
|
|
1908
|
+
p.add_argument("--summary", required=True, help="做成了什么,一句话")
|
|
1909
|
+
p.add_argument(
|
|
1910
|
+
"--finished", help="结案时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
|
|
1911
|
+
)
|
|
1912
|
+
p.add_argument("--category", help="结案时修正分类(不传则沿用 new 时的值)")
|
|
1913
|
+
p.add_argument("--tags", help="结案时修正标签(不传则沿用 new 时的值)")
|
|
1914
|
+
add_repo(p)
|
|
1915
|
+
p.set_defaults(func=cmd_close)
|
|
1916
|
+
|
|
1917
|
+
p = sub.add_parser("list", help="查询在办任务")
|
|
1918
|
+
p.add_argument("--mine", action="store_true", help="只看自己的")
|
|
1919
|
+
p.add_argument("--owner", help="只看某个人的")
|
|
1920
|
+
p.add_argument("--status", help="逗号分隔,如 doing,blocked")
|
|
1921
|
+
p.add_argument("--category", help="按分类过滤(精确匹配)")
|
|
1922
|
+
p.add_argument("--tag", help="按标签过滤(包含匹配,命中任一面即算)")
|
|
1923
|
+
p.add_argument(
|
|
1924
|
+
"--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
|
|
1925
|
+
)
|
|
1926
|
+
p.add_argument(
|
|
1927
|
+
"--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
|
|
1928
|
+
)
|
|
1929
|
+
p.add_argument("--json", action="store_true", help="输出原始 JSON")
|
|
1930
|
+
add_repo(p)
|
|
1931
|
+
p.set_defaults(func=cmd_list)
|
|
1932
|
+
|
|
1933
|
+
p = sub.add_parser("show", help="单任务详情")
|
|
1934
|
+
p.add_argument("--id", required=True)
|
|
1935
|
+
add_repo(p)
|
|
1936
|
+
p.set_defaults(func=cmd_show)
|
|
1937
|
+
|
|
1938
|
+
p = sub.add_parser(
|
|
1939
|
+
"tags", help="分类/标签巡检(--all)与归并(--rename 旧 新,只改在办任务)"
|
|
1940
|
+
)
|
|
1941
|
+
p.add_argument("--all", action="store_true", help="输出全量分类/标签频次表")
|
|
1942
|
+
p.add_argument(
|
|
1943
|
+
"--rename",
|
|
1944
|
+
nargs=2,
|
|
1945
|
+
metavar=("旧", "新"),
|
|
1946
|
+
help="把在办任务里等于「旧」的分类/标签改为「新」,同步改在办 worklog 头部",
|
|
1947
|
+
)
|
|
1948
|
+
add_repo(p)
|
|
1949
|
+
p.set_defaults(func=cmd_tags)
|
|
1950
|
+
|
|
1951
|
+
p = sub.add_parser(
|
|
1952
|
+
"history",
|
|
1953
|
+
help="查已结案任务(只读):--owner / --mine / --since / --until / --limit / --json / --category / --tag",
|
|
1954
|
+
)
|
|
1955
|
+
p.add_argument("--owner", help="只看某个人的(精确匹配,不做前缀/子串)")
|
|
1956
|
+
p.add_argument("--mine", action="store_true", help="只看自己的(当前 git 身份)")
|
|
1957
|
+
p.add_argument("--category", help="按分类过滤(精确匹配;回链读 worklog 头部)")
|
|
1958
|
+
p.add_argument("--tag", help="按标签过滤(包含匹配;回链读 worklog 头部)")
|
|
1959
|
+
p.add_argument(
|
|
1960
|
+
"--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
|
|
1961
|
+
)
|
|
1962
|
+
p.add_argument(
|
|
1963
|
+
"--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
|
|
1964
|
+
)
|
|
1965
|
+
p.add_argument("--limit", type=int, help="过滤 + 倒序后取前 N 条(最近 N 条)")
|
|
1966
|
+
p.add_argument("--json", action="store_true", help="输出结构化 JSON(含摘要)")
|
|
1967
|
+
p.add_argument(
|
|
1968
|
+
"--count-only",
|
|
1969
|
+
action="store_true",
|
|
1970
|
+
help="只数行数(封顶 1 万,输出 {count, capped});显示级统计,精确数以 --json 为准",
|
|
1971
|
+
)
|
|
1972
|
+
p.add_argument(
|
|
1973
|
+
"--months",
|
|
1974
|
+
type=int,
|
|
1975
|
+
help="只读最近 N 个月的历史文件(ledger/history/*.md 按文件名取末 N 个;不含存量平铺文件)",
|
|
1976
|
+
)
|
|
1977
|
+
add_repo(p)
|
|
1978
|
+
p.set_defaults(func=cmd_history)
|
|
1979
|
+
|
|
1980
|
+
p = sub.add_parser("validate", help="校验状态表与 worklog")
|
|
1981
|
+
add_repo(p)
|
|
1982
|
+
p.set_defaults(func=cmd_validate)
|
|
1983
|
+
|
|
1984
|
+
return parser
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1988
|
+
args = build_parser().parse_args(argv)
|
|
1989
|
+
try:
|
|
1990
|
+
args.func(args)
|
|
1991
|
+
except Fail as exc:
|
|
1992
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
1993
|
+
return 1
|
|
1994
|
+
except KeyboardInterrupt:
|
|
1995
|
+
print("已中断。", file=sys.stderr)
|
|
1996
|
+
return 130
|
|
1997
|
+
except BrokenPipeError:
|
|
1998
|
+
# 输出被下游截断(如 | head):静默退出,不打 traceback
|
|
1999
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
2000
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
2001
|
+
return 0
|
|
2002
|
+
except OSError as exc:
|
|
2003
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
2004
|
+
return 1
|
|
2005
|
+
except Exception as exc: # noqa: BLE001 —— 兜底:未预期的失败用独立退出码,与业务错误区分
|
|
2006
|
+
print(f"错误:未预期的失败 {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
2007
|
+
return 3
|
|
2008
|
+
return 0
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
if __name__ == "__main__":
|
|
2012
|
+
sys.exit(main())
|