feat: generate task board from task frontmatter

This commit is contained in:
chengma
2026-07-08 10:47:36 +08:00
parent d1d4f458cb
commit 8a7ce0acba
4 changed files with 309 additions and 2 deletions
+22
View File
@@ -0,0 +1,22 @@
# 任务看板
> 本文件由 `python scripts/gen_task_board.py` 自动生成,请勿手改。
> 数据源只包含 `docs/tasks/T-*.md`;`docs/06-tasks.md` 是 T-000~T-549 历史归档,不纳入本看板。
## 汇总
- 总任务:2
- 状态统计:BLOCKED 0 · DOING 0 · DONE 2 · TODO 0
- 下一个可领取:暂无
## Phase 7
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-550 | ①②③ 批次下拉默认选中最新批次 | T-523, T-206 | DONE |
## Phase 8
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-551 | 脚本从 docs/tasks/ frontmatter 生成任务看板 | - | DONE |
+6 -2
View File
@@ -3,7 +3,7 @@ id: T-551
title: 脚本从 docs/tasks/ frontmatter 生成任务看板 title: 脚本从 docs/tasks/ frontmatter 生成任务看板
phase: 8 phase: 8
deps: [] deps: []
status: TODO status: DONE
created: 2026-07-08 created: 2026-07-08
--- ---
@@ -39,4 +39,8 @@ created: 2026-07-08
## 执行记录 ## 执行记录
(做完在此记录:改了哪些文件、跑的验证命令与结果、决策) - 2026-07-08:完成 T-551。
- 代码:新增 `scripts/gen_task_board.py`,用标准库扫描 `docs/tasks/T-*.md`,手写解析 frontmatter;字段缺失、格式错误、非法 status/phase/deps 的任务文件会跳过并通过 stderr 提示,不让脚本崩溃;按 phase 分组输出 `id / title / deps / status`,并计算状态统计和“下一个可领取”。
- 文档产物:运行 `python scripts/gen_task_board.py` 生成 `docs/tasks-board.md`,文件顶部标注由脚本生成、请勿手改;数据源仅包含 `docs/tasks/T-*.md`,不纳入冻结归档 `docs/06-tasks.md`。
- 测试:新增 `tests/test_task_board.py`,用临时任务目录覆盖正常任务、依赖已完成的下一个可领取任务、依赖未完成任务、进行中任务,以及缺字段 frontmatter 被跳过。
- 验证:`py -3.10 -m unittest discover -s tests -p "test_task_board.py"` 通过(1 test);`python scripts/gen_task_board.py` 通过并生成 2 个任务;`python -m ruff check app tests main.py` 通过;`python -m ruff check scripts\gen_task_board.py tests\test_task_board.py` 通过;`py -3.10 -m compileall app main.py` 通过;`py -3.10 -m py_compile scripts/gen_task_board.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(254 tests)。
+194
View File
@@ -0,0 +1,194 @@
"""Generate a read-only task board from docs/tasks frontmatter."""
from __future__ import annotations
import argparse
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_TASKS_DIR = REPO_ROOT / "docs" / "tasks"
DEFAULT_OUTPUT_PATH = REPO_ROOT / "docs" / "tasks-board.md"
REQUIRED_FIELDS = {"id", "title", "phase", "deps", "status", "created"}
VALID_STATUSES = {"TODO", "DOING", "DONE", "BLOCKED"}
@dataclass(frozen=True)
class Task:
id: str
title: str
phase: int
deps: tuple[str, ...]
status: str
created: str
path: Path
def parse_deps(raw_value):
value = str(raw_value or "").strip()
if value == "[]":
return ()
if not (value.startswith("[") and value.endswith("]")):
raise ValueError("deps 必须使用 [T-xxx, ...] 格式")
inner = value[1:-1].strip()
if not inner:
return ()
deps = []
for item in inner.split(","):
dep = item.strip().strip("'\"")
if not dep:
raise ValueError("deps 中存在空依赖")
deps.append(dep)
return tuple(deps)
def parse_frontmatter(path):
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
raise ValueError("缺少 frontmatter 起始 ---")
end_index = None
for index, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
end_index = index
break
if end_index is None:
raise ValueError("缺少 frontmatter 结束 ---")
data = {}
for line in lines[1:end_index]:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if ":" not in stripped:
raise ValueError(f"frontmatter 行格式错误:{stripped}")
key, value = stripped.split(":", 1)
data[key.strip()] = value.strip()
missing = sorted(REQUIRED_FIELDS - set(data))
if missing:
raise ValueError("缺少字段:" + "、".join(missing))
try:
phase = int(str(data["phase"]).strip())
except ValueError as exc:
raise ValueError("phase 必须是整数") from exc
deps = parse_deps(data["deps"])
status = str(data["status"]).strip()
if status not in VALID_STATUSES:
raise ValueError("status 必须是 TODO/DOING/DONE/BLOCKED")
task_id = str(data["id"]).strip()
if not re.fullmatch(r"T-\d+[A-Za-z]*", task_id):
raise ValueError("id 必须是 T-数字 或 T-数字后缀")
return Task(
id=task_id,
title=str(data["title"]).strip(),
phase=phase,
deps=deps,
status=status,
created=str(data["created"]).strip(),
path=path,
)
def task_sort_key(task_or_id):
task_id = task_or_id.id if isinstance(task_or_id, Task) else str(task_or_id)
match = re.fullmatch(r"T-(\d+)([A-Za-z]*)", task_id)
if not match:
return (10**9, task_id)
return (int(match.group(1)), match.group(2))
def load_tasks(tasks_dir):
tasks = []
warnings = []
for path in sorted(Path(tasks_dir).glob("T-*.md")):
try:
tasks.append(parse_frontmatter(path))
except ValueError as exc:
warnings.append(f"{path.name}: {exc}")
tasks.sort(key=task_sort_key)
return tasks, warnings
def next_claimable_task(tasks):
done_ids = {task.id for task in tasks if task.status == "DONE"}
for task in sorted(tasks, key=task_sort_key):
if task.status == "TODO" and all(dep in done_ids for dep in task.deps):
return task
return None
def markdown_escape(value):
return str(value).replace("|", "\\|").replace("\n", " ")
def render_board(tasks):
status_counts = Counter(task.status for task in tasks)
next_task = next_claimable_task(tasks)
lines = [
"# 任务看板",
"",
"> 本文件由 `python scripts/gen_task_board.py` 自动生成,请勿手改。",
"> 数据源只包含 `docs/tasks/T-*.md`;`docs/06-tasks.md` 是 T-000~T-549 历史归档,不纳入本看板。",
"",
"## 汇总",
"",
f"- 总任务:{len(tasks)}",
"- 状态统计:"
+ " · ".join(f"{status} {status_counts.get(status, 0)}" for status in sorted(VALID_STATUSES)),
"- 下一个可领取:"
+ (f"{next_task.id}({next_task.title})" if next_task is not None else "暂无"),
"",
]
grouped = defaultdict(list)
for task in tasks:
grouped[task.phase].append(task)
for phase in sorted(grouped):
lines.extend(
[
f"## Phase {phase}",
"",
"| ID | 任务 | 依赖 | 状态 |",
"| --- | --- | --- | --- |",
]
)
for task in sorted(grouped[phase], key=task_sort_key):
deps = ", ".join(task.deps) if task.deps else "-"
lines.append(
"| {id} | {title} | {deps} | {status} |".format(
id=markdown_escape(task.id),
title=markdown_escape(task.title),
deps=markdown_escape(deps),
status=markdown_escape(task.status),
)
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def generate_board(tasks_dir=DEFAULT_TASKS_DIR, output_path=DEFAULT_OUTPUT_PATH):
tasks, warnings = load_tasks(tasks_dir)
output = render_board(tasks)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output, encoding="utf-8")
return tasks, warnings, output
def main(argv=None):
parser = argparse.ArgumentParser(description="从 docs/tasks frontmatter 生成只读任务看板。")
parser.add_argument("--tasks-dir", default=str(DEFAULT_TASKS_DIR), help="任务文件目录")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 Markdown 文件")
args = parser.parse_args(argv)
tasks, warnings, _output = generate_board(args.tasks_dir, args.output)
for warning in warnings:
print(f"跳过任务文件:{warning}", file=sys.stderr)
print(f"已生成 {args.output},任务 {len(tasks)} 个")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from _helpers import REPO_ROOT, TempDirMixin
def load_task_board_module():
script_path = REPO_ROOT / "scripts" / "gen_task_board.py"
spec = importlib.util.spec_from_file_location("gen_task_board", script_path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class TaskBoardTests(TempDirMixin, unittest.TestCase):
def write_task(
self,
tasks_dir,
task_id,
title,
phase,
deps,
status,
created="2026-07-08",
):
deps_text = "[" + ", ".join(deps) + "]"
path = Path(tasks_dir) / f"{task_id}.md"
path.write_text(
"\n".join(
[
"---",
f"id: {task_id}",
f"title: {title}",
f"phase: {phase}",
f"deps: {deps_text}",
f"status: {status}",
f"created: {created}",
"---",
"",
"## 问题 / 背景",
"",
]
),
encoding="utf-8",
)
return path
def test_generate_board_from_task_frontmatter_and_skips_invalid_files(self):
module = load_task_board_module()
with self.make_temp_dir() as temp_dir:
tasks_dir = Path(temp_dir) / "tasks"
tasks_dir.mkdir()
output_path = Path(temp_dir) / "tasks-board.md"
self.write_task(tasks_dir, "T-550", "已完成任务", 7, [], "DONE")
self.write_task(tasks_dir, "T-551", "可领取任务", 8, ["T-550"], "TODO")
self.write_task(tasks_dir, "T-552", "依赖未完成任务", 8, ["T-999"], "TODO")
self.write_task(tasks_dir, "T-553", "进行中任务", 8, [], "DOING")
(tasks_dir / "T-554.md").write_text(
"---\nid: T-554\ntitle: 缺字段任务\n---\n",
encoding="utf-8",
)
tasks, warnings, output = module.generate_board(tasks_dir, output_path)
self.assertEqual(["T-550", "T-551", "T-552", "T-553"], [task.id for task in tasks])
self.assertEqual(1, len(warnings))
self.assertIn("T-554.md", warnings[0])
self.assertTrue(output_path.exists())
content = output_path.read_text(encoding="utf-8")
self.assertEqual(output, content)
self.assertIn("请勿手改", content)
self.assertIn("下一个可领取:T-551(可领取任务)", content)
self.assertIn("| T-550 | 已完成任务 | - | DONE |", content)
self.assertIn("| T-551 | 可领取任务 | T-550 | TODO |", content)
self.assertIn("TODO 2", content)
self.assertIn("DOING 1", content)
self.assertNotIn("T-554", content)
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()