diff --git a/docs/tasks-board.md b/docs/tasks-board.md new file mode 100644 index 0000000..1707226 --- /dev/null +++ b/docs/tasks-board.md @@ -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 | diff --git a/docs/tasks/T-551.md b/docs/tasks/T-551.md index 2cbd4f1..da335eb 100644 --- a/docs/tasks/T-551.md +++ b/docs/tasks/T-551.md @@ -3,7 +3,7 @@ id: T-551 title: 脚本从 docs/tasks/ frontmatter 生成任务看板 phase: 8 deps: [] -status: TODO +status: DONE 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)。 diff --git a/scripts/gen_task_board.py b/scripts/gen_task_board.py new file mode 100644 index 0000000..6a48e4e --- /dev/null +++ b/scripts/gen_task_board.py @@ -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()) diff --git a/tests/test_task_board.py b/tests/test_task_board.py new file mode 100644 index 0000000..34570ca --- /dev/null +++ b/tests/test_task_board.py @@ -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()