task-ledger 1.3.3 → 1.3.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 +2 -2
- package/SKILL.md +32 -19
- package/package.json +1 -1
- package/references/state-model.md +9 -7
- package/references/troubleshooting.md +19 -7
- package/scripts/ledgerkit/actions/atom.py +2 -2
- package/scripts/ledgerkit/actions/bootstrap.py +9 -2
- package/scripts/ledgerkit/actions/claim.py +7 -6
- package/scripts/ledgerkit/actions/close_task.py +13 -12
- package/scripts/ledgerkit/actions/guard.py +38 -16
- package/scripts/ledgerkit/actions/handoff.py +19 -8
- package/scripts/ledgerkit/actions/init_identity.py +90 -0
- package/scripts/ledgerkit/actions/list_tasks.py +21 -3
- package/scripts/ledgerkit/actions/log_progress.py +5 -2
- package/scripts/ledgerkit/actions/new_task.py +11 -7
- package/scripts/ledgerkit/actions/query.py +4 -2
- package/scripts/ledgerkit/actions/query_history.py +48 -9
- package/scripts/ledgerkit/actions/registry.py +1 -1
- package/scripts/ledgerkit/actions/release.py +25 -4
- package/scripts/ledgerkit/actions/set_task.py +18 -7
- package/scripts/ledgerkit/actions/validate.py +0 -4
- package/scripts/ledgerkit/core/history_md.py +20 -245
- package/scripts/ledgerkit/core/identity.py +20 -7
- package/scripts/ledgerkit/core/labels.py +0 -1
- package/scripts/ledgerkit/core/paths.py +0 -1
- package/scripts/ledgerkit/env.py +70 -71
- package/scripts/ledgerkit/shell/cli.py +60 -3
- package/scripts/ledgerkit/shell/gitops.py +14 -1
- package/scripts/ledgerkit/shell/render.py +3 -2
- package/scripts/ledgerkit/shell/runner.py +1 -1
- package/scripts/ledgerkit/store/history_store.py +23 -80
- package/scripts/ledgerkit/store/owners_index.py +16 -3
- package/scripts/ledgerkit/store/task_store.py +2 -2
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""历史表格的文本操作:拼九列一行、解析九列一行。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
回调进来(`str -> str | None`),core 只做文本换算。
|
|
3
|
+
**只认九列这一种格式**(开发期没发布,不做任何旧列数 / 旧列语义的迁移)。
|
|
5
4
|
"""
|
|
6
5
|
|
|
7
6
|
from __future__ import annotations
|
|
8
7
|
|
|
9
8
|
import re
|
|
10
|
-
from collections.abc import
|
|
11
|
-
from pathlib import Path
|
|
9
|
+
from collections.abc import Iterable
|
|
12
10
|
from typing import Any
|
|
13
11
|
|
|
14
12
|
from .paths import to_history_rel
|
|
@@ -18,8 +16,6 @@ HISTORY_TITLE = "# 进度历史(已结案)"
|
|
|
18
16
|
HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
|
|
19
17
|
HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
|
|
20
18
|
|
|
21
|
-
# 最旧格式把 owner 缝在工作包末尾:最右一个「空格 + @」之后整段是 owner。
|
|
22
|
-
OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
|
|
23
19
|
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
|
|
24
20
|
_TAG_SPLIT_RE = re.compile(r"[,,、]")
|
|
25
21
|
|
|
@@ -32,7 +28,7 @@ def esc_cell(text: str) -> str:
|
|
|
32
28
|
|
|
33
29
|
|
|
34
30
|
def owner_cell(owner: str | None) -> str:
|
|
35
|
-
"""owner 列的展示值:人名前加 @(`@`
|
|
31
|
+
"""owner 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸 uuid)。"""
|
|
36
32
|
return f"@{owner}" if owner else ""
|
|
37
33
|
|
|
38
34
|
|
|
@@ -56,7 +52,6 @@ def is_sep_row(cells: list[str]) -> bool:
|
|
|
56
52
|
|
|
57
53
|
|
|
58
54
|
def _looks_like_header(cells: list[str]) -> bool:
|
|
59
|
-
# 按内容判、不按位置判:无表头的老文件首行就是数据,按位置判会把第一条记录当表头删掉。
|
|
60
55
|
return bool(cells) and cells[0] in ("时间", "结案时间")
|
|
61
56
|
|
|
62
57
|
|
|
@@ -73,7 +68,7 @@ def history_line(
|
|
|
73
68
|
) -> str:
|
|
74
69
|
"""九列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
|
|
75
70
|
|
|
76
|
-
owner / creator
|
|
71
|
+
owner / creator 传**裸 uuid**(`@` 由 owner_cell / creator_cell 加),外面别先加。
|
|
77
72
|
"""
|
|
78
73
|
log_rel = to_history_rel(worklog) if worklog else ""
|
|
79
74
|
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
@@ -86,66 +81,11 @@ def history_line(
|
|
|
86
81
|
)
|
|
87
82
|
|
|
88
83
|
|
|
89
|
-
def history_line_for_cols(
|
|
90
|
-
cols: int,
|
|
91
|
-
finished: str,
|
|
92
|
-
created: str | None,
|
|
93
|
-
title: str,
|
|
94
|
-
owner: str | None,
|
|
95
|
-
summary: str,
|
|
96
|
-
worklog: str | None,
|
|
97
|
-
category: str | None = None,
|
|
98
|
-
tags: Iterable[str] | None = None,
|
|
99
|
-
creator: str | None = None,
|
|
100
|
-
) -> str:
|
|
101
|
-
"""「历史层有无法解析的行、不敢整体升级」时按现有列数追加一行,避免列错位。
|
|
102
|
-
|
|
103
|
-
GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。少列的文件硬塞 9 列会把整行挤歪。
|
|
104
|
-
"""
|
|
105
|
-
log_rel = to_history_rel(worklog) if worklog else ""
|
|
106
|
-
link = f"[{log_rel}]({log_rel})" if log_rel else ""
|
|
107
|
-
if cols <= 4:
|
|
108
|
-
who = f" @{owner}" if owner else ""
|
|
109
|
-
return (
|
|
110
|
-
f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} | {link} |\n"
|
|
111
|
-
)
|
|
112
|
-
if cols == 5:
|
|
113
|
-
return (
|
|
114
|
-
f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
|
|
115
|
-
f"| {esc_cell(summary)} | {link} |\n"
|
|
116
|
-
)
|
|
117
|
-
if cols == 6:
|
|
118
|
-
return (
|
|
119
|
-
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
120
|
-
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {link} |\n"
|
|
121
|
-
)
|
|
122
|
-
return history_line(
|
|
123
|
-
finished, created, title, owner, summary, worklog, category, tags, creator
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def history_cols(text: str) -> int:
|
|
128
|
-
"""现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
|
|
129
|
-
counts: dict[int, int] = {}
|
|
130
|
-
for raw in text.splitlines():
|
|
131
|
-
if not raw.strip().startswith("|"):
|
|
132
|
-
continue
|
|
133
|
-
cells = split_row(raw)
|
|
134
|
-
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
135
|
-
continue
|
|
136
|
-
if len(cells) >= 4:
|
|
137
|
-
counts[len(cells)] = counts.get(len(cells), 0) + 1
|
|
138
|
-
if not counts:
|
|
139
|
-
return 6
|
|
140
|
-
return max(counts, key=lambda k: (counts[k], k))
|
|
141
|
-
|
|
142
|
-
|
|
143
84
|
def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
144
|
-
"""
|
|
85
|
+
"""解析九列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
|
|
145
86
|
|
|
146
|
-
兼容 9 / 8 / 6 / 5(两种)/ 4 列;坏行(列数不在列、时间列不合法)跳过并警告,不中断。
|
|
147
87
|
返回的键:finished / created / title / category / tags / owner / creator / summary /
|
|
148
|
-
worklog / fmt / line。owner 与 creator 一律**剥掉
|
|
88
|
+
worklog / fmt / line。owner 与 creator 一律**剥掉 `@`**(存储值是裸 uuid)。
|
|
149
89
|
"""
|
|
150
90
|
records: list[Row] = []
|
|
151
91
|
warnings: list[str] = []
|
|
@@ -155,196 +95,31 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
155
95
|
cells = split_row(raw)
|
|
156
96
|
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
157
97
|
continue
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
tags: list[str] = []
|
|
161
|
-
creator: str | None = None
|
|
162
|
-
if len(cells) == 9:
|
|
163
|
-
(
|
|
164
|
-
finished,
|
|
165
|
-
created,
|
|
166
|
-
title,
|
|
167
|
-
category,
|
|
168
|
-
tag_cell,
|
|
169
|
-
owner,
|
|
170
|
-
creator,
|
|
171
|
-
summary,
|
|
172
|
-
doc,
|
|
173
|
-
) = cells
|
|
174
|
-
fmt = "v9"
|
|
175
|
-
category = category.strip() or None
|
|
176
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
177
|
-
creator = creator.strip().removeprefix("@") or None
|
|
178
|
-
elif len(cells) == 8:
|
|
179
|
-
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
180
|
-
fmt = "v8"
|
|
181
|
-
category = category.strip() or None
|
|
182
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
183
|
-
elif len(cells) == 6:
|
|
184
|
-
finished, created, title, owner, summary, doc = cells
|
|
185
|
-
fmt = "v6"
|
|
186
|
-
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
187
|
-
# 旧 ledger 格式(结案 / 创建 / 工作包 / 摘要 / 文档):行内自带创建时间。
|
|
188
|
-
finished, created, title, summary, doc = cells
|
|
189
|
-
owner = ""
|
|
190
|
-
fmt = "v5ledger"
|
|
191
|
-
elif len(cells) == 5:
|
|
192
|
-
finished, title, owner, summary, doc = cells
|
|
193
|
-
fmt = "v5"
|
|
194
|
-
elif len(cells) == 4:
|
|
195
|
-
finished, title, summary, doc = cells
|
|
196
|
-
m = OLD_OWNER_RE.match(title)
|
|
197
|
-
owner = m.group("owner") if m else ""
|
|
198
|
-
if m:
|
|
199
|
-
title = m.group("title")
|
|
200
|
-
fmt = "v4"
|
|
201
|
-
else:
|
|
202
|
-
warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
|
|
98
|
+
if len(cells) != 9:
|
|
99
|
+
warnings.append(f"第 {lineno} 行不是九列(历史表只认九列),已跳过")
|
|
203
100
|
continue
|
|
101
|
+
finished, created, title, category, tag_cell, owner, creator, summary, doc = (
|
|
102
|
+
cells
|
|
103
|
+
)
|
|
204
104
|
if not TIME_RE.fullmatch(finished):
|
|
205
105
|
warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
|
|
206
106
|
continue
|
|
207
107
|
m = _LINK_RE.fullmatch(doc.strip())
|
|
208
|
-
log = m.group(1) if m else doc.strip()
|
|
209
108
|
records.append(
|
|
210
109
|
{
|
|
211
110
|
"finished": finished,
|
|
212
111
|
"created": time_key(created) if created else None,
|
|
213
112
|
"title": title,
|
|
214
|
-
"category": category,
|
|
215
|
-
"tags": tuple(
|
|
216
|
-
|
|
217
|
-
|
|
113
|
+
"category": category.strip() or None,
|
|
114
|
+
"tags": tuple(
|
|
115
|
+
t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
|
|
116
|
+
),
|
|
117
|
+
"owner": owner.strip().removeprefix("@") or None,
|
|
118
|
+
"creator": creator.strip().removeprefix("@") or None,
|
|
218
119
|
"summary": summary,
|
|
219
|
-
"worklog":
|
|
220
|
-
"fmt":
|
|
120
|
+
"worklog": (m.group(1) if m else doc.strip()) or None,
|
|
121
|
+
"fmt": "v9",
|
|
221
122
|
"line": lineno,
|
|
222
123
|
}
|
|
223
124
|
)
|
|
224
125
|
return records, warnings
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _drop_trailing_blank(out: list[str]) -> None:
|
|
228
|
-
"""去掉已在结果里的尾部空行(丢弃重复表头时用):中间的空行会把 GFM 表格截断。"""
|
|
229
|
-
while out and not out[-1].strip():
|
|
230
|
-
out.pop()
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def upgrade_history(
|
|
234
|
-
text: str,
|
|
235
|
-
*,
|
|
236
|
-
read_worklog: Callable[[str], dict[str, Any] | None],
|
|
237
|
-
fallback_created: str,
|
|
238
|
-
) -> tuple[str, int] | None:
|
|
239
|
-
"""旧格式(4 / 5 / 6 / 8 列)→ v9。**全部数据行可解析才返回 (新文本, 转换行数)**,否则 None。
|
|
240
|
-
|
|
241
|
-
`None` 的语义是「不敢动」:调用方必须退回 `history_line_for_cols` 按原列数追加并明示,
|
|
242
|
-
绝不半升级、绝不猜。分类 / 标签 / creator / 缺的创建时间都靠 `read_worklog` 回链补,
|
|
243
|
-
读不到就留空(创建时间除外:留空会让排序失效,所以退化到 `fallback_created`)。
|
|
244
|
-
|
|
245
|
-
`read_worklog` 的契约:`(log_name: str) -> dict | None` —— 收**文件名**(历史末列的
|
|
246
|
-
basename,不是路径),返回 `{"created", "creator", "category", "tags"}`。core 只消费
|
|
247
|
-
返回值,自己不开文件。
|
|
248
|
-
"""
|
|
249
|
-
out: list[str] = []
|
|
250
|
-
converted = 0
|
|
251
|
-
seen_header = False
|
|
252
|
-
seen_sep = False
|
|
253
|
-
for raw in text.splitlines():
|
|
254
|
-
if not raw.strip().startswith("|"):
|
|
255
|
-
out.append(raw)
|
|
256
|
-
continue
|
|
257
|
-
cells = split_row(raw)
|
|
258
|
-
if not cells:
|
|
259
|
-
out.append(raw)
|
|
260
|
-
continue
|
|
261
|
-
if is_sep_row(cells):
|
|
262
|
-
if seen_sep:
|
|
263
|
-
_drop_trailing_blank(out)
|
|
264
|
-
continue
|
|
265
|
-
seen_sep = True
|
|
266
|
-
out.append(HISTORY_SEP)
|
|
267
|
-
continue
|
|
268
|
-
if _looks_like_header(cells):
|
|
269
|
-
if seen_header:
|
|
270
|
-
_drop_trailing_blank(out)
|
|
271
|
-
continue
|
|
272
|
-
seen_header = True
|
|
273
|
-
out.append(HISTORY_HEADER)
|
|
274
|
-
continue
|
|
275
|
-
if len(cells) == 9:
|
|
276
|
-
if time_key(cells[0]) is None:
|
|
277
|
-
return None
|
|
278
|
-
out.append("| " + " | ".join(cells) + " |")
|
|
279
|
-
continue
|
|
280
|
-
explicit_created: str | None = None
|
|
281
|
-
creator: str | None = None
|
|
282
|
-
if len(cells) == 8:
|
|
283
|
-
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
284
|
-
if time_key(finished) is None:
|
|
285
|
-
return None
|
|
286
|
-
created_key = time_key(created.strip())
|
|
287
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
288
|
-
creator = (_meta_of(doc, read_worklog) or {}).get("creator")
|
|
289
|
-
out.append(
|
|
290
|
-
f"| {finished} | {created_key or ''} | {esc_cell(title)} "
|
|
291
|
-
f"| {esc_cell(category.strip())} "
|
|
292
|
-
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
293
|
-
f"| {esc_cell(owner_cell(owner.lstrip('@')))} "
|
|
294
|
-
f"| {esc_cell(creator_cell(creator))} "
|
|
295
|
-
f"| {esc_cell(summary)} | {doc} |"
|
|
296
|
-
)
|
|
297
|
-
converted += 1
|
|
298
|
-
continue
|
|
299
|
-
if len(cells) == 6:
|
|
300
|
-
finished, created, title, owner, summary, doc = cells
|
|
301
|
-
owner = owner.lstrip("@")
|
|
302
|
-
explicit_created = time_key(created.strip())
|
|
303
|
-
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
304
|
-
finished, created_cell, title, summary, doc = cells
|
|
305
|
-
owner = ""
|
|
306
|
-
explicit_created = time_key(created_cell.strip())
|
|
307
|
-
elif len(cells) == 5:
|
|
308
|
-
finished, title, owner, summary, doc = cells
|
|
309
|
-
owner = owner.lstrip("@")
|
|
310
|
-
elif len(cells) == 4:
|
|
311
|
-
finished, title, summary, doc = cells
|
|
312
|
-
m = OLD_OWNER_RE.match(title)
|
|
313
|
-
owner = m.group("owner") if m else ""
|
|
314
|
-
if m:
|
|
315
|
-
title = m.group("title")
|
|
316
|
-
else:
|
|
317
|
-
return None
|
|
318
|
-
finished_key = time_key(finished)
|
|
319
|
-
if finished_key is None:
|
|
320
|
-
return None
|
|
321
|
-
meta = _meta_of(doc, read_worklog)
|
|
322
|
-
created = explicit_created or (meta or {}).get("created") or fallback_created
|
|
323
|
-
category = (meta or {}).get("category")
|
|
324
|
-
tags = tuple((meta or {}).get("tags") or ())
|
|
325
|
-
creator = (meta or {}).get("creator")
|
|
326
|
-
out.append(
|
|
327
|
-
f"| {finished_key} | {created} | {esc_cell(title)} "
|
|
328
|
-
f"| {esc_cell(category or '')} "
|
|
329
|
-
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
330
|
-
f"| {esc_cell(owner_cell(owner))} "
|
|
331
|
-
f"| {esc_cell(creator_cell(creator))} "
|
|
332
|
-
f"| {esc_cell(summary)} | {doc} |"
|
|
333
|
-
)
|
|
334
|
-
converted += 1
|
|
335
|
-
return "\n".join(out) + "\n", converted
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _link_target(cell: str) -> str:
|
|
339
|
-
m = _LINK_RE.fullmatch((cell or "").strip())
|
|
340
|
-
return (m.group(1) if m else (cell or "")).strip()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _meta_of(
|
|
344
|
-
doc: str, read_worklog: Callable[[str], dict[str, Any] | None]
|
|
345
|
-
) -> dict[str, Any] | None:
|
|
346
|
-
"""顺着历史行的文档链接取回 worklog 头部四样(`read_worklog` 收文件名)。"""
|
|
347
|
-
target = _link_target(doc)
|
|
348
|
-
if not target:
|
|
349
|
-
return None
|
|
350
|
-
return read_worklog(Path(target).name)
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""身份这个值对象:uuid 是唯一键,name 只用于显示与查询筛选。
|
|
2
|
+
|
|
3
|
+
**只做字符串换算,不读文件**(读身份归 env.py)。名字永远不参与归属判定:
|
|
4
|
+
认领 / 释放 / 交接 / 查「我的」,一律只比 uuid。
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
9
|
+
import re
|
|
5
10
|
from dataclasses import dataclass
|
|
6
11
|
|
|
12
|
+
#: 合法身份键的形态(8-4-4-4-12 的十六进制 UUID)。`--owner` 只收这种值。
|
|
13
|
+
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
14
|
+
|
|
7
15
|
|
|
8
16
|
@dataclass(frozen=True)
|
|
9
17
|
class Identity:
|
|
10
|
-
"""一个人的身份。`
|
|
18
|
+
"""一个人的身份。`uuid` 是唯一键,`name` 只用于显示。"""
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
uuid: str
|
|
13
21
|
name: str = ""
|
|
14
22
|
|
|
15
23
|
def label(self) -> str:
|
|
16
|
-
"""
|
|
17
|
-
return self.name or self.
|
|
24
|
+
"""显示名:没名字就用 uuid 前 8 位。"""
|
|
25
|
+
return self.name or self.uuid[:8]
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
def normalize(who: str) -> str:
|
|
@@ -22,6 +30,11 @@ def normalize(who: str) -> str:
|
|
|
22
30
|
return (who or "").strip().lower()
|
|
23
31
|
|
|
24
32
|
|
|
33
|
+
def looks_like_uuid(who: str | None) -> bool:
|
|
34
|
+
"""是不是一个 UUID 形态的身份键(名字、邮箱一律 False)。"""
|
|
35
|
+
return bool(UUID_RE.match(normalize(who or "")))
|
|
36
|
+
|
|
37
|
+
|
|
25
38
|
def is_me(identity: Identity, who: str | None) -> bool:
|
|
26
|
-
"""`who
|
|
27
|
-
return normalize(who or "") == normalize(identity.
|
|
39
|
+
"""`who`(uuid)是不是这个人。全站只认 uuid 这一个身份键。"""
|
|
40
|
+
return normalize(who or "") == normalize(identity.uuid)
|
package/scripts/ledgerkit/env.py
CHANGED
|
@@ -1,93 +1,92 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""确定「我是谁」的唯一地方:只读机器级身份文件,不读 git、不读环境变量。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
兜底身份这件事本身只 `notices.note("提示:…")`,不打印。
|
|
3
|
+
身份文件:`$TASK_LEDGER_HOME/.taskledger/identity.json`(默认 `~/.taskledger/identity.json`),
|
|
4
|
+
内容是 `{"uuid": "...", "name": "..."}`。没有就报错,由 `init` 命令创建。
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
9
8
|
|
|
10
|
-
import
|
|
9
|
+
import json
|
|
11
10
|
import os
|
|
12
|
-
import re
|
|
13
|
-
import socket
|
|
14
|
-
import subprocess
|
|
15
11
|
from pathlib import Path
|
|
16
12
|
|
|
17
|
-
from . import
|
|
18
|
-
from .
|
|
13
|
+
from .core.identity import Identity, looks_like_uuid, normalize
|
|
14
|
+
from .errors import Fail
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
IDENTITY_HOME_ENV = "TASK_LEDGER_HOME"
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
def
|
|
24
|
-
|
|
19
|
+
def identity_file() -> Path:
|
|
20
|
+
"""机器级身份文件路径;`TASK_LEDGER_HOME` 可覆写主目录(容器/测试用)。"""
|
|
21
|
+
base = os.environ.get(IDENTITY_HOME_ENV)
|
|
22
|
+
if base:
|
|
23
|
+
return Path(base) / ".taskledger" / "identity.json"
|
|
24
|
+
try:
|
|
25
|
+
home = Path.home()
|
|
26
|
+
except RuntimeError as exc:
|
|
27
|
+
raise Fail(
|
|
28
|
+
"找不到用户主目录(HOME)。\n"
|
|
29
|
+
" 处理:设置环境变量 TASK_LEDGER_HOME 指向一个可写目录,再重试。"
|
|
30
|
+
) from exc
|
|
31
|
+
return home / ".taskledger" / "identity.json"
|
|
25
32
|
|
|
26
33
|
|
|
27
|
-
def
|
|
28
|
-
"""
|
|
34
|
+
def read_identity() -> Identity | None:
|
|
35
|
+
"""读机器身份;文件不存在、空文件、坏 JSON、没 uuid 一律返回 None。
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
uuid 先归一化(去空白 + 小写,与 `--owner` 入参同一口径)。**形态不对直接报错**:
|
|
38
|
+
身份键只能是 uuid,手改进去一个邮箱 / 名字会让每条命令都写错 owner,
|
|
39
|
+
与其静默用下去,不如当场停下让人改文件。
|
|
32
40
|
"""
|
|
33
|
-
|
|
41
|
+
path = identity_file()
|
|
34
42
|
try:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
except FileNotFoundError:
|
|
43
|
+
raw = path.read_text(encoding="utf-8")
|
|
44
|
+
except (OSError, ValueError):
|
|
39
45
|
return None
|
|
40
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
41
|
-
return proc.stdout.strip()
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def git_email(repo: Path | None) -> str | None:
|
|
46
|
-
"""GIT_COMMITTER_IDENT 优先,退回 user.email。"""
|
|
47
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
48
|
-
if ident:
|
|
49
|
-
m = _IDENT_RE.search(ident)
|
|
50
|
-
if m and m.group(1).strip():
|
|
51
|
-
return m.group(1).strip()
|
|
52
|
-
return git_value(repo, "config", "user.email")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def git_name(repo: Path | None) -> str | None:
|
|
56
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
57
|
-
if ident:
|
|
58
|
-
head = ident.split("<", 1)[0].strip()
|
|
59
|
-
if head:
|
|
60
|
-
return head
|
|
61
|
-
return git_value(repo, "config", "user.name")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def fallback_identity() -> str:
|
|
65
|
-
"""不在 git 仓库里时的兜底链:系统用户名 → getpass → 主机名 → 字面 default。"""
|
|
66
|
-
for key in ("USER", "LOGNAME"):
|
|
67
|
-
value = os.environ.get(key, "").strip()
|
|
68
|
-
if value:
|
|
69
|
-
return value
|
|
70
|
-
try:
|
|
71
|
-
name = getpass.getuser().strip()
|
|
72
|
-
except Exception: # noqa: BLE001 —— 拿不到用户名是正常分支,不该炸
|
|
73
|
-
name = ""
|
|
74
|
-
if name:
|
|
75
|
-
return name
|
|
76
46
|
try:
|
|
77
|
-
|
|
78
|
-
except
|
|
79
|
-
|
|
80
|
-
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
uuid = normalize(str((data or {}).get("uuid") or ""))
|
|
51
|
+
if not uuid:
|
|
52
|
+
return None
|
|
53
|
+
if not looks_like_uuid(uuid):
|
|
54
|
+
raise Fail(
|
|
55
|
+
f"身份文件里的 uuid 不合法:{path}\n"
|
|
56
|
+
f" 读到:{uuid}(身份键只能是 uuid,不能是邮箱 / 名字)\n"
|
|
57
|
+
" 处理:把该文件的 uuid 改成 uuid(或删掉这个文件重跑 init)。"
|
|
58
|
+
)
|
|
59
|
+
name = str((data or {}).get("name") or "").strip()
|
|
60
|
+
return Identity(uuid=uuid, name=name)
|
|
81
61
|
|
|
82
62
|
|
|
83
63
|
def current(repo: Path | None = None) -> Identity:
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
"""当前操作者;没初始化就报错,提示去 init。
|
|
65
|
+
|
|
66
|
+
name 的取值规则:工作区 owners.json 里同 uuid 的 name 优先,没有就用总体的 name。
|
|
67
|
+
"""
|
|
68
|
+
identity = read_identity()
|
|
69
|
+
if identity is None:
|
|
70
|
+
raise Fail(
|
|
71
|
+
"这台机器还没有初始化身份。\n"
|
|
72
|
+
" 处理:先运行 `progress.py init --repo <工作区根>`,输入一个名字,再执行本命令。"
|
|
91
73
|
)
|
|
92
|
-
name =
|
|
93
|
-
|
|
74
|
+
name = identity.name
|
|
75
|
+
if repo is not None:
|
|
76
|
+
name = _workspace_name(repo, identity.uuid) or name
|
|
77
|
+
return Identity(uuid=identity.uuid, name=name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _workspace_name(repo: Path, uuid: str) -> str:
|
|
81
|
+
"""当前工作区 owners.json 里这个 uuid 的 name;没有/读不到返回空串。"""
|
|
82
|
+
from .core.paths import OWNERS_REL, to_repo_rel
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
|
|
86
|
+
workers = (json.loads(raw) or {}).get("workers", {}) or {}
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return ""
|
|
89
|
+
record = workers.get(uuid) or workers.get(uuid.lower())
|
|
90
|
+
if not isinstance(record, dict):
|
|
91
|
+
return ""
|
|
92
|
+
return str(record.get("name") or "").strip()
|