From 9bc1d3abe22edb0b31bcc1ff605ee4f62b3678cd Mon Sep 17 00:00:00 2001 From: fig-ai-agent Date: Thu, 10 Sep 2026 08:35:35 +0000 Subject: [PATCH] feat(tasks): add a folder-based task tracker (new / inprogress / done) Tracks what is done, in progress, and not started, without opening GitHub. A task's folder IS its status: new.inprogress.done/new/ not started (may be blocked) new.inprogress.done/inprogress/ started, not finished new.inprogress.done/done/ finished and verified Each task is Markdown: front-matter (id, title, status, priority, created, updated, owner, repo, issue, prs, blocked_by, tokens) then fixed sections -- goal, scope, out of scope, steps, acceptance criteria, dependencies/blockers, files changed, validation, token usage, notes, completion summary. Docs (English, matching the repo's existing documentation): README.md purpose, status meanings, create/move process, required file format, naming convention, Definition of Done, and an example -- plus an explicit note that `tokens:` is an estimate (len(text) // 4), not a billing figure TASK_TEMPLATE.md the template itself tools/tasks.py is a stdlib-only CLI: list (with [blocked] markers), report (counts + estimated tokens), new (next free id), move (relocates the file AND rewrites status:, so the two cannot drift). tests/ enforces the structure instead of trusting it: required front-matter keys, unique ids, ISO dates, status matching the containing folder, and a PR reference on anything in done/. 15 tests pass. Seeded with four real tasks: Issue #63 / pure-agent-dev (done), the CHANGELOG+README update (done), the new-crystalcastle SHA fix (inprogress, blocked on write access), and the GitHub Actions repair (new, blocked on `.github/workflows/` permission) -- the last is the worked example. Documentation and process only: no application code, and nothing under app/, tests/, or the project configuration is touched. --- new.inprogress.done/README.md | 137 ++++++++++++++ new.inprogress.done/TASK_TEMPLATE.md | 71 +++++++ new.inprogress.done/done/.gitkeep | 0 ...ASK-20260910-001-issue63-pure-agent-dev.md | 67 +++++++ ...SK-20260910-002-update-changelog-readme.md | 52 +++++ new.inprogress.done/inprogress/.gitkeep | 0 .../TASK-20260910-003-ncc-ci-sha-fix.md | 55 ++++++ new.inprogress.done/new/example-task.md | 94 +++++++++ new.inprogress.done/tests/conftest.py | 7 + new.inprogress.done/tests/test_tasks.py | 135 +++++++++++++ new.inprogress.done/tools/tasks.py | 178 ++++++++++++++++++ 11 files changed, 796 insertions(+) create mode 100644 new.inprogress.done/README.md create mode 100644 new.inprogress.done/TASK_TEMPLATE.md create mode 100644 new.inprogress.done/done/.gitkeep create mode 100644 new.inprogress.done/done/TASK-20260910-001-issue63-pure-agent-dev.md create mode 100644 new.inprogress.done/done/TASK-20260910-002-update-changelog-readme.md create mode 100644 new.inprogress.done/inprogress/.gitkeep create mode 100644 new.inprogress.done/inprogress/TASK-20260910-003-ncc-ci-sha-fix.md create mode 100644 new.inprogress.done/new/example-task.md create mode 100644 new.inprogress.done/tests/conftest.py create mode 100644 new.inprogress.done/tests/test_tasks.py create mode 100644 new.inprogress.done/tools/tasks.py diff --git a/new.inprogress.done/README.md b/new.inprogress.done/README.md new file mode 100644 index 0000000..3540ba2 --- /dev/null +++ b/new.inprogress.done/README.md @@ -0,0 +1,137 @@ +# Task Tracking (`new` / `inprogress` / `done`) + +A folder-based task tracker at the repository root. A task's **folder is its +status**, so progress is visible in a file listing — no tooling required. + +> This is documentation and process only. It contains no application code and +> changes nothing under `app/`, `tests/`, or the project's configuration. + +## The three statuses + +| Folder | Status | Means | +| --- | --- | --- | +| `new/` | Not started | The work is understood but not begun. May be blocked. | +| `inprogress/` | In progress | Started, not finished. Open work is listed in the task. | +| `done/` | Done | Finished **and verified** — see Definition of Done. | + +A task is "done" when it meets the Definition of Done below, not when the code +is written. Blocked work still lives in `new/` if it was never started, or in +`inprogress/` if it stalled partway; the `blocked_by:` field records what it is +waiting on. + +## Creating a task + +1. Copy `TASK_TEMPLATE.md` into `new/`. +2. Rename it to the naming convention below. +3. Fill in the front-matter and every section. Leave nothing blank — write + "none" rather than omitting a section. + +## Moving a task between statuses + +A task moves between folders by **moving the file and editing its `status:` +field**. The two must always agree; a file whose `status:` disagrees with its +folder is a bug, and `tests/test_tasks.py` fails on it. + +```bash +# move the file, then set status: inprogress and update updated: +mv new/TASK-20260910-004-example.md inprogress/ +``` + +`tools/tasks.py` does both at once so they cannot drift: + +```bash +python3 tools/tasks.py move TASK-20260910-004 inprogress +``` + +Other commands: + +```bash +python3 tools/tasks.py list # all tasks by status, with [blocked] markers +python3 tools/tasks.py report # counts + total estimated tokens +python3 tools/tasks.py new "Title" # create from the template with the next id +``` + +## Required task file format + +Every task is Markdown: YAML-ish front-matter, then fixed sections. + +**Front-matter** (all keys required except `issue`, `prs`, `blocked_by`): + +```yaml +--- +id: TASK-YYYYMMDD-NNN # unique; used by `move` +title: Short title +status: new # new | inprogress | done — must match the folder +priority: normal # low | normal | high +created: YYYY-MM-DD +updated: YYYY-MM-DD # bump on every change +owner: # who is doing it (optional) +repo: ZyntroAI/fastapi-python-boilerplate +issue: # issue number, if any +prs: [] # PR numbers — required for anything in done/ +blocked_by: # what it waits on, if anything +tokens: 0 # estimated tokens, see below +--- +``` + +**Sections** (in this order): Goal · Scope · Out of scope · Steps · +Acceptance criteria · Dependencies / blockers · Files changed · Validation · +Token usage · Notes · Completion summary. + +See `new/example-task.md` for a filled-in example. + +## Naming convention + +``` +-.md +``` + +- **ID** — `TASK-YYYYMMDD-NNN`, the creation date plus a per-day sequence, e.g. + `TASK-20260910-001`. +- **slug** — the title, lowercased, spaces to `-`, ASCII only, ≤ 50 characters. +- Example: `TASK-20260910-001-issue63-pure-agent-dev.md` + +`TASK_TEMPLATE.md` itself is the only file in this directory that does not +follow the convention. + +## Definition of Done + +A task may enter `done/` only when all of these hold: + +1. Every item in **Steps** is complete, or its omission is explained in Notes. +2. Every **Acceptance criterion** is met and checked. +3. The **Validation** table is filled in with the actual commands run and their + real results — not "should pass". +4. `prs:` lists the merged PR, or the task records the commit SHA. A task with + no evidence does not go in `done/`. +5. `status: done` and the file is in `done/`. +6. Anything not finished is written in **Out of scope** or **Notes** — never + left silently undone. + +## Token usage + +`tokens:` is an **estimate**, computed with this project's `len(text) // 4` +rule over the files a task lists. It is a size proxy for comparing tasks, not a +billing figure — real provider usage also includes the system prompt and the +context read into the session. The README says so plainly so nobody bills +against it. + +## Validation + +The structure is enforced, not trusted: + +```bash +python3 -m pytest tests/ # from inside new.inprogress.done/ +``` + +The tests fail when a task is missing a front-matter key, uses a duplicate +`id`, has a non-ISO date, disagrees with its own folder, or sits in `done/` +without a PR reference. + +## Practical example + +`new/example-task.md` is a real task in this repository: fixing the GitHub +Actions workflows, which currently fail at the *Set up job* step because +actions are referenced by tag instead of by pinned commit SHA. It is blocked on +write access to `.github/workflows/`, and it says so in `blocked_by:` — which is +why `tools/tasks.py list` prints it with a `[blocked]` marker. diff --git a/new.inprogress.done/TASK_TEMPLATE.md b/new.inprogress.done/TASK_TEMPLATE.md new file mode 100644 index 0000000..d1bcfe8 --- /dev/null +++ b/new.inprogress.done/TASK_TEMPLATE.md @@ -0,0 +1,71 @@ +--- +id: TASK-YYYYMMDD-NNN +title: Short, specific title +status: new +priority: normal +created: YYYY-MM-DD +updated: YYYY-MM-DD +owner: +repo: ZyntroAI/fastapi-python-boilerplate +issue: +prs: [] +blocked_by: +tokens: 0 +--- + +# TASK-YYYYMMDD-NNN — Short, specific title + +## Goal + +One or two sentences. What is true after this ships that is not true now? + +## Scope + +- What this task covers. + +## Out of scope + +- What it deliberately does not cover, and why. Prevents the task growing. + +## Steps + +- [ ] Step one +- [ ] Step two + +## Acceptance criteria + +- [ ] A specific, checkable condition. Not "works well" — "returns 200 with X". +- [ ] Existing tests still pass. + +## Dependencies / blockers + +What this waits on. If nothing, write "None." If something external, name it — +it will show as `[blocked]` in `tools/tasks.py list`. + +## Files changed + +| File | Change | +| --- | --- | +| `path/to/file` | Added / modified / deleted | + +## Validation + +| Command | Result | +| --- | --- | +| | | + +Actual commands and actual results. Not "should pass". + +## Token usage + +Estimated with `len(text) // 4` over the files above: **0**. This is a size +proxy, not a measured API figure. + +## Notes + +Anything a reader needs that does not fit above. + +## Completion summary + +Fill in only when moving to `done/`. What shipped, the PR or commit, and +anything knowingly left undone. diff --git a/new.inprogress.done/done/.gitkeep b/new.inprogress.done/done/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/new.inprogress.done/done/TASK-20260910-001-issue63-pure-agent-dev.md b/new.inprogress.done/done/TASK-20260910-001-issue63-pure-agent-dev.md new file mode 100644 index 0000000..a6313c6 --- /dev/null +++ b/new.inprogress.done/done/TASK-20260910-001-issue63-pure-agent-dev.md @@ -0,0 +1,67 @@ +--- +id: TASK-20260910-001 +title: ปิด Issue #63 — pure-agent-dev reference implementation +status: done +priority: high +created: 2026-09-10 +updated: 2026-09-10 +owner: fig-agent +repo: ZyntroAI/fastapi-python-boilerplate +issue: 63 +prs: [169, 170] +blocked_by: +tokens: 48200 +--- + +# TASK-20260910-001 — ปิด Issue #63 + +## เป้าหมาย + +Issue #63 ("pure-agent-dev") ขอโครง Agent บน FastAPI ที่ไม่ผูกกับคลาวด์รายใดรายหนึ่ง พร้อมกฎว่าชั้น Agent ต้องไม่แตะ SDK ของ BytePlus + +## งานที่ทำ + +- [x] อ่าน Code Guide ฉบับเต็มใน Issue #63 (body + คอมเมนต์ 8 ฉบับ) แล้วแยกข้อกำหนดออกมาเป็นรายการ +- [x] สร้าง `pure_agent/` แยกชั้น providers / agents / services / schemas / api +- [x] ทำ `ComputeProvider` (ABC) + `MockComputeProvider` + BytePlus ECS adapter +- [x] แยก `planner.py` (คำสั่ง → AgentTask) กับ `executor.py` (AgentTask → provider) +- [x] เลือก provider ด้วย env `COMPUTE_PROVIDER` ที่ `api/deps.py` จุดเดียว +- [x] เขียน JSON Schema ภายนอก + Pydantic model แล้วมี test เทียบกันสองทาง +- [x] เขียน `tests/test_architecture.py` เดิน import graph จริง บังคับกฎ "Agent ห้าม import byteplus" +- [x] เขียน Dockerfile + docker-compose + pyproject + CI workflow ประจำ deliverable +- [x] รัน `pytest` 47/47 (ทั้งโหมดปกติและ `-O`) + `ruff` clean +- [x] เปิด PR #169 แล้ว merge (squash `590b8615`) → Issue ปิดอัตโนมัติ +- [x] เปิด PR #170 อัปเดต CHANGELOG แล้ว merge (squash `3ff8da60`) + +## ไฟล์ที่ถูกแก้ + +| ไฟล์ | การเปลี่ยนแปลง | +| --- | --- | +| `deliverables/pure-agent-dev/pure_agent/providers/base.py` | สร้างใหม่ — `ComputeProvider` ABC | +| `deliverables/pure-agent-dev/pure_agent/providers/mock.py` | สร้างใหม่ — provider ในหน่วยความจำ | +| `deliverables/pure-agent-dev/pure_agent/providers/byteplus/ecs.py` | สร้างใหม่ — adapter (SDK call เป็น `TODO(byteplus)`) | +| `deliverables/pure-agent-dev/pure_agent/agents/planner.py` | สร้างใหม่ | +| `deliverables/pure-agent-dev/pure_agent/agents/executor.py` | สร้างใหม่ — เปลี่ยน `assert` เป็น `raise` หลัง test จับได้ | +| `deliverables/pure-agent-dev/pure_agent/api/deps.py` | สร้างใหม่ — จุดเลือก provider จุดเดียว | +| `deliverables/pure-agent-dev/schemas/agent-task.schema.json` | สร้างใหม่ — สัญญาภายนอก | +| `deliverables/pure-agent-dev/tests/test_architecture.py` | สร้างใหม่ — guard กฎสถาปัตยกรรม | +| `CHANGELOG.md` | แก้ — บันทึก PR #169 | + +## Token ที่ใช้ + +ประมาณการ `len(text) // 4` จากไฟล์ที่แก้: **48,200** + +## ผลตรวจ + +| คำสั่ง | ผล | +| --- | --- | +| `pytest` | 47 passed | +| `python -O -m pytest` | 47 passed | +| `ruff check .` | clean | +| `jsonschema.validate` | valid draft 2020-12, ตรงกับ Pydantic | +| `GET /health` | 200 บน mock provider ไม่ต้องมี credential | + +## งานค้าง + +- **ตัวเรียก BytePlus SDK ยังเป็น stub** — `providers/byteplus/ecs.py` เป็น `TODO(byteplus)` signature จบแล้ว แต่ยังไม่เคยยิงคลาวด์จริงจาก environment นี้ +- ยังไม่มี adapter ตัวที่สอง (AWS/Azure) ให้เป็นตัวอย่างจริง diff --git a/new.inprogress.done/done/TASK-20260910-002-update-changelog-readme.md b/new.inprogress.done/done/TASK-20260910-002-update-changelog-readme.md new file mode 100644 index 0000000..e6b3c90 --- /dev/null +++ b/new.inprogress.done/done/TASK-20260910-002-update-changelog-readme.md @@ -0,0 +1,52 @@ +--- +id: TASK-20260910-002 +title: อัปเดต CHANGELOG.md และ README.md +status: done +priority: normal +created: 2026-09-10 +updated: 2026-09-10 +owner: fig-agent +repo: ZyntroAI/fastapi-python-boilerplate +issue: +prs: [171] +blocked_by: +tokens: 9800 +--- + +# TASK-20260910-002 — อัปเดตเอกสาร + +## เป้าหมาย + +CHANGELOG มีบันทึก CI ที่ล้าสมัยแล้ว (บอกว่ารอ grant `workflows` ซึ่งได้ไปแล้วแต่ยัง push ไม่ได้) และ README ยังไม่ตรงกับ `deliverables/` จริง + +## งานที่ทำ + +- [x] เพิ่ม entry PR #170 + `### Fixed` บันทึกว่า Issue #63 ปิดผ่าน PR #169 +- [x] ตรวจจำนวน action ref ที่ยังไม่ pin บน `main` แบบวัดจริง แล้วเขียนแทนข้อความเดิม +- [x] ระบุชัดว่าการแก้ต้องมีสิทธิ์เขียน `.github/workflows/` ซึ่ง App ทำไม่ได้ ต้องให้ maintainer แก้ +- [x] อัปเดตแถว `deliverables/` ใน README ให้ตรงของจริง +- [x] เพิ่มสถานะ CI ใต้ *Repository health & standards* แทนการปล่อยให้ดูเหมือนทุกอย่างเขียว +- [x] เปิด PR #171 แล้ว merge (squash `ae7e737`) + +## ไฟล์ที่ถูกแก้ + +| ไฟล์ | การเปลี่ยนแปลง | +| --- | --- | +| `CHANGELOG.md` | แก้ — เพิ่มหมวด 2026-09-10 และแทนข้อความ CI ที่ล้าสมัย | +| `README.md` | แก้ — แถว deliverables + สถานะ CI | + +## Token ที่ใช้ + +ประมาณการ `len(text) // 4` จากไฟล์ที่แก้: **9,800** + +## ผลตรวจ + +| คำสั่ง | ผล | +| --- | --- | +| PR #171 merged | state=MERGED, +8/−2, 2 ไฟล์, ไม่แตะ `.github/workflows/` | +| ยืนยันบน `main` | ข้อความ `fix/sha-pin-all-workflows` หายไป (`grep -c` = 0) | +| ตรวจการอ้างอิง | `CONTRIBUTING.md`, `SECURITY.md`, `RELEASE.md` มีอยู่จริง | + +## งานค้าง + +- ไม่มี diff --git a/new.inprogress.done/inprogress/.gitkeep b/new.inprogress.done/inprogress/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/new.inprogress.done/inprogress/TASK-20260910-003-ncc-ci-sha-fix.md b/new.inprogress.done/inprogress/TASK-20260910-003-ncc-ci-sha-fix.md new file mode 100644 index 0000000..cfecb48 --- /dev/null +++ b/new.inprogress.done/inprogress/TASK-20260910-003-ncc-ci-sha-fix.md @@ -0,0 +1,55 @@ +--- +id: TASK-20260910-003 +title: แก้ SHA ที่ไม่ถูกต้องใน CI ของ new-crystalcastle +status: inprogress +priority: high +created: 2026-09-10 +updated: 2026-09-10 +owner: fig-agent +repo: ZyntroAI/new-crystalcastle +issue: +prs: [] +blocked_by: ต้องมีสิทธิ์เขียน repo (grant_write_access) +tokens: 15600 +--- + +# TASK-20260910-003 — แก้ SHA ปลอมใน workflow + +## เป้าหมาย + +CI ล้มทุก run ที่ขั้น *Set up job* ด้วย `Unable to resolve action` เพราะมี SHA ที่ไม่มีอยู่จริงใน `codeql.yml` + +## งานที่ทำ + +- [x] ยืนยัน SHA กับ GitHub จริง: 2 ตัวที่แจ้งว่าเสียไม่มีอยู่ (HTTP 422) และตัวใหม่มีอยู่จริง +- [x] สแกนทุกไฟล์ workflow แล้วพบว่า**พัง 4 ref ไม่ใช่ 2** — codeql SHA เดียวกันถูก pin ไว้ทั้ง 3 step +- [x] ยืนยันสาเหตุจาก log ของ run 34444649712 ตรงกับที่แจ้ง +- [x] แก้ครบ 4 ref + ใส่คอมเมนต์ `# v4` / `# v3` กำกับ tag +- [x] validate YAML ผ่าน 12/12 +- [x] commit พร้อมข้อความ Conventional Commits แล้ว +- [ ] push branch — **ติดสิทธิ์**: `403 refusing to allow a GitHub App to create or update workflow` +- [ ] เปิด PR + +## ไฟล์ที่ถูกแก้ + +| ไฟล์ | การเปลี่ยนแปลง | +| --- | --- | +| `.github/workflows/codeql.yml` | แก้ — checkout SHA 1 ref + codeql SHA 3 ref (init / autobuild / analyze) | + +## Token ที่ใช้ + +ประมาณการ `len(text) // 4` จากไฟล์ที่แก้: **15,600** + +## ผลตรวจ + +| คำสั่ง | ผล | +| --- | --- | +| `yaml.safe_load` ทุกไฟล์ | 12/12 ผ่าน | +| grep SHA ปลอม | ไม่เหลือ | +| `gh api .../commits/` | ตัวใหม่ 200, ตัวเก่า 422 | + +## งานค้าง + +- **ติดสิทธิ์เขียน repo** — ขอ `grant_write_access` แล้ว รออนุมัติ +- ยังมีอีก 2 ไฟล์ที่ YAML พังแบบเดิม (คนละสาเหตุ) ต้องซ่อมต่อ +- ยังไม่เปิด PR diff --git a/new.inprogress.done/new/example-task.md b/new.inprogress.done/new/example-task.md new file mode 100644 index 0000000..a8f8382 --- /dev/null +++ b/new.inprogress.done/new/example-task.md @@ -0,0 +1,94 @@ +--- +id: TASK-20260910-004 +title: Repair the GitHub Actions workflows — YAML errors and unpinned actions +status: new +priority: high +created: 2026-09-10 +updated: 2026-09-10 +owner: fig-agent +repo: ZyntroAI/fastapi-python-boilerplate +issue: +prs: [] +blocked_by: write access to .github/workflows/ (the automation App lacks the workflows permission) +tokens: 0 +--- + +# TASK-20260910-004 — Repair the GitHub Actions workflows + +## Goal + +Make CI actually run. Today every job fails at the *Set up job* step, so no PR +on this repository can show a green check — even when its own tests pass. + +## Scope + +- Repair the workflow files that do not parse as YAML. +- Pin every action reference to a full commit SHA, per the org's policy. +- Move non-workflow files out of `.github/workflows/`. + +## Out of scope + +- Changing what any workflow *does*. Every edit is structural — parse errors and + reference pinning only. +- Renaming the three mis-named workflow files. Two of them deploy on + `push: main` / `push: develop`; renaming would start deploys that have never + run. That is a behaviour change and needs the owner's decision first. + +## Steps + +- [x] Inspect the repository and measure the real state (do not assume). +- [x] Confirm the failure cause from an actual run log. +- [ ] Move non-workflow files out of `.github/workflows/` into `archive/`. +- [ ] Pin all action refs to full SHAs, resolving each against GitHub. +- [ ] Re-validate every workflow as YAML. +- [ ] Open a PR. + +## Acceptance criteria + +- [ ] All workflow files parse as YAML (measured: 5 of 12 currently do not). +- [ ] No `uses:` reference uses a mutable tag such as `@v4`. +- [ ] `.github/workflows/` contains only workflow YAML. +- [ ] No workflow's behaviour changes. +- [ ] A PR whose own tests pass shows green checks. + +## Dependencies / blockers + +**Blocked.** Writing `.github/workflows/` requires the App's `workflows` +permission, which is not granted; pushes are rejected with +`refusing to allow a GitHub App to create or update workflow ... without +workflows permission`. A maintainer must apply this, or the permission must be +raised. Work done earlier in a sandbox was also reclaimed before it could be +committed, so this restarts from current `main`. + +## Files changed + +| File | Change | +| --- | --- | +| *none yet — blocked before commit* | | + +## Validation + +| Command | Result | +| --- | --- | +| YAML parse across `.github/workflows/*.y*ml` | **not run yet** — 5 files known broken | +| Count of `uses:` refs still on a tag | **not measured yet** | + +## Token usage + +Estimated with `len(text) // 4` over the files above: **0**. Not started. + +## Notes + +Measured on 2026-09-10, `main` still mixes pinned SHAs with mutable tags: +`actions/checkout@v4` (17 refs), `actions/setup-python@v5` (7), +`actions/upload-artifact@v4` (6), `subosito/flutter-action@v2` (5), +`somaz94/compress-decompress@v1` (5), and others. + +The five files that do not parse each fail differently: one is wrapped in a +Markdown ```yaml fence, one has a multi-line heredoc inside a `run: |` block, +one has ~87 lines of GitHub documentation appended to the end, one uses a `;` +where a `:` belongs, and one has a flow mapping whose braces nest. + +## Completion summary + +*Not complete.* diff --git a/new.inprogress.done/tests/conftest.py b/new.inprogress.done/tests/conftest.py new file mode 100644 index 0000000..d71560f --- /dev/null +++ b/new.inprogress.done/tests/conftest.py @@ -0,0 +1,7 @@ +"""ให้ tests import `tasks` ได้ โดยไม่ต้องติดตั้งเป็น package""" +import sys +from pathlib import Path + +TOOLS = Path(__file__).resolve().parent.parent / "tools" +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) diff --git a/new.inprogress.done/tests/test_tasks.py b/new.inprogress.done/tests/test_tasks.py new file mode 100644 index 0000000..81c5a6a --- /dev/null +++ b/new.inprogress.done/tests/test_tasks.py @@ -0,0 +1,135 @@ +"""ตรวจว่าโครง task tracker ยังถูกต้อง — ไม่ใช่แค่มีโฟลเดอร์ แต่ข้อมูลต้องสอดคล้องกัน""" +import datetime as dt +import re + +import tasks +import pytest + +ROOT = tasks.ROOT +STATUSES = tasks.STATUSES +REQUIRED_KEYS = { + "id", "title", "status", "priority", "created", "updated", + "owner", "repo", "prs", "tokens", +} +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def all_tasks(): + return list(tasks.iter_tasks()) + + +def test_status_folders_exist(): + for status in STATUSES: + assert (ROOT / status).is_dir(), f"ไม่มีโฟลเดอร์ {status}/" + + +def test_every_task_has_front_matter(): + tasks = all_tasks() + assert tasks, "ไม่พบไฟล์งานเลย" + for _status, path, fm in tasks: + missing = REQUIRED_KEYS - set(fm) + assert not missing, f"{path.name} ขาด key: {sorted(missing)}" + + +def test_status_matches_folder(): + """สถานะใน front-matter ต้องตรงกับโฟลเดอร์ — กันงานเสร็จแล้วค้างใน inprogress/""" + for status, path, fm in all_tasks(): + assert fm["status"] == status, ( + f"{path.name}: status='{fm['status']}' แต่ไฟล์อยู่ {status}/" + ) + + +def test_status_is_valid_value(): + for _status, path, fm in all_tasks(): + assert fm["status"] in STATUSES, f"{path.name}: status ไม่ถูกต้อง" + + +def test_ids_unique(): + ids = [fm["id"] for _s, _p, fm in all_tasks()] + dupes = {i for i in ids if ids.count(i) > 1} + assert not dupes, f"id ซ้ำ: {sorted(dupes)}" + + +def test_dates_are_iso(): + for _status, path, fm in all_tasks(): + for key in ("created", "updated"): + assert DATE_RE.match(fm[key]), f"{path.name}: {key}='{fm[key]}' ต้องเป็น YYYY-MM-DD" + dt.date.fromisoformat(fm[key]) + + +def test_done_tasks_have_evidence(): + """งานที่ปิดแล้วต้องอ้าง PR ได้ — ไม่งั้น 'done' ไม่มีหลักฐาน""" + for status, path, fm in all_tasks(): + if status != "done": + continue + assert fm.get("prs", "[]") not in ("[]", ""), ( + f"{path.name}: อยู่ใน done/ แต่ไม่มี PR อ้างอิง" + ) + + +def test_token_field_is_numeric(): + for _status, path, fm in all_tasks(): + int(fm["tokens"]), f"{path.name}: tokens ต้องเป็นตัวเลข" + + +def test_estimate_tokens_uses_len_div_4(): + assert tasks.estimate_tokens("a" * 40) == 10 + assert tasks.estimate_tokens("") == 0 + + +def test_read_front_matter_roundtrip(tmp_path): + f = tmp_path / "t.md" + f.write_text("---\nid: X\nstatus: new\n---\n\n# body\n", encoding="utf-8") + assert tasks.read_front_matter(f) == {"id": "X", "status": "new"} + + +def test_write_front_matter_value(tmp_path): + f = tmp_path / "t.md" + f.write_text("---\nstatus: new\nupdated: 2026-01-01\n---\n\n# body\n", encoding="utf-8") + tasks.write_front_matter_value(f, "status", "done") + assert tasks.read_front_matter(f)["status"] == "done" + + +def test_report_runs(capsys): + assert tasks.main(["report"]) == 0 + out = capsys.readouterr().out + assert "สรุปภาพรวม" in out + + +def test_list_runs(capsys): + assert tasks.main(["list"]) == 0 + assert "งาน" in capsys.readouterr().out + + +def test_move_updates_status_and_folder(tmp_path, monkeypatch): + """ย้ายงานแล้วทั้งโฟลเดอร์และ front-matter ต้องเปลี่ยนตามกัน""" + fixed = tmp_path + for s in STATUSES: + (fixed / s).mkdir() + monkeypatch.setattr(tasks, "ROOT", fixed) + (fixed / "new" / "TASK-X.md").write_text( + "---\nid: TASK-X\ntitle: t\nstatus: new\ncreated: 2026-01-01\n" + "updated: 2026-01-01\nprs: []\ntokens: 0\n---\n# x\n", encoding="utf-8") + + assert tasks.main(["move", "TASK-X", "done"]) == 0 + assert not (fixed / "new" / "TASK-X.md").exists() + moved = fixed / "done" / "TASK-X.md" + assert moved.exists() + assert tasks.read_front_matter(moved)["status"] == "done" + + +def test_new_creates_file_in_new(tmp_path, monkeypatch): + fixed = tmp_path + for s in STATUSES: + (fixed / s).mkdir() + # cmd_new reads TASK_TEMPLATE.md from ROOT -- there is no templates/ subdir. + (fixed / "TASK_TEMPLATE.md").write_text( + "---\nid: TASK-YYYYMMDD-NNN\ntitle: short title\nstatus: new\n" + "created: YYYY-MM-DD\nupdated: YYYY-MM-DD\n---\n# x\n", encoding="utf-8") + monkeypatch.setattr(tasks, "ROOT", fixed) + + assert tasks.main(["new", "ทดสอบ สร้างงาน"]) == 0 + created = list((fixed / "new").glob("*.md")) + assert len(created) == 1 + fm = tasks.read_front_matter(created[0]) + assert fm["title"] == "ทดสอบ สร้างงาน" diff --git a/new.inprogress.done/tools/tasks.py b/new.inprogress.done/tools/tasks.py new file mode 100644 index 0000000..04f3e45 --- /dev/null +++ b/new.inprogress.done/tools/tasks.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""tasks — Task tracker CLI. + +คำสั่ง: + list รายการงานทั้งหมด + สรุปจำนวนตามสถานะ + report สรุปภาพรวม รวม token ที่ประมาณไว้ + new "ชื่องาน" สร้างงานใหม่ใน new/ (ใช้ template) + move ย้ายงานไป new|inprogress|done แล้วแก้ front-matter ให้ตรง + +ไม่พึ่ง dependency ภายนอก — ใช้แค่ stdlib +""" +from __future__ import annotations + +import argparse +import datetime as dt +import re +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +STATUSES = ("new", "inprogress", "done") +ID_RE = re.compile(r"^id:\s*(\S+)$", re.M) + + +def estimate_tokens(text: str) -> int: + """ประมาณจำนวน token ด้วยกฎ len(text) // 4 ของโปรเจกต์นี้""" + return len(text) // 4 + + +def read_front_matter(path: Path) -> dict: + """อ่าน front-matter แบบง่าย (key: value) ไม่ต้องใช้ PyYAML""" + text = path.read_text(encoding="utf-8") + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end == -1: + return {} + fm = {} + for line in text[3:end].strip().splitlines(): + if ":" in line and not line.startswith(" "): + key, _, value = line.partition(":") + fm[key.strip()] = value.strip().strip('"').strip("'") + return fm + + +def write_front_matter_value(path: Path, key: str, value: str) -> None: + text = path.read_text(encoding="utf-8") + new, n = re.subn(rf"^{key}:\s*.*$", f"{key}: {value}", text, count=1, flags=re.M) + if n == 0: + raise SystemExit(f"ไม่พบ key '{key}' ใน {path.name}") + path.write_text(new, encoding="utf-8") + + +def iter_tasks(): + """ให้ (status, path, front_matter) ของทุกไฟล์งาน""" + for status in STATUSES: + for path in sorted((ROOT / status).glob("*.md")): + yield status, path, read_front_matter(path) + + +def cmd_list(args) -> int: + ok = True + for status in STATUSES: + rows = [(p, fm) for s, p, fm in iter_tasks() if s == status] + print(f"\n[{status}] {len(rows)} งาน") + if not rows: + print(" (ว่าง)") + continue + for path, fm in rows: + block = fm.get("blocked_by", "") + mark = " [ติด: " + block + "]" if block else "" + print(f" {fm.get('id', '?'):<22} {fm.get('title', path.stem)}{mark}") + if status != fm.get("status"): + print(f" ! status ใน front-matter = '{fm.get('status')}' ไม่ตรงกับโฟลเดอร์") + ok = False + return 0 if ok else 1 + + +def cmd_report(args) -> int: + tasks = list(iter_tasks()) + counts = {s: 0 for s in STATUSES} + total = 0 + blocked = 0 + for status, path, fm in tasks: + counts[status] += 1 + try: + total += int(fm.get("tokens") or 0) + except ValueError: + pass + if fm.get("blocked_by"): + blocked += 1 + + print("=" * 58) + print("task tracker — สรุปภาพรวม") + print("=" * 58) + for status in STATUSES: + print(f" {status:<12} {counts[status]:>3}") + print(f" {'รวม':<12} {len(tasks):>3}") + print(f"\n ติด blocker {blocked}") + print(f" token ประมาณ {total:,}") + print(f"\n อัปเดตล่าสุด: {dt.date.today().isoformat()}") + return 0 + + +def cmd_new(args) -> int: + today = dt.date.today().strftime("%Y%m%d") + existing = {fm.get("id", "") for _, _, fm in iter_tasks()} + n = 1 + while f"TASK-{today}-{n:03d}" in existing: + n += 1 + task_id = f"TASK-{today}-{n:03d}" + + template = (ROOT / "TASK_TEMPLATE.md").read_text(encoding="utf-8") + title = args.title + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:50] or "task" + # Substituting the title by placeholder was brittle: it silently did nothing + # when the template's wording changed, leaving the file titled "short title". + # Rewrite the front-matter lines directly instead. + today = dt.date.today().isoformat() + body = template.replace("TASK-YYYYMMDD-NNN", task_id) + body = re.sub(r"^title:.*$", f"title: {title}", body, count=1, flags=re.M) + body = re.sub(r"^created:.*$", f"created: {today}", body, count=1, flags=re.M) + body = re.sub(r"^updated:.*$", f"updated: {today}", body, count=1, flags=re.M) + body = body.replace("YYYY-MM-DD", today) + + dest = ROOT / "new" / f"{task_id}-{slug}.md" + dest.write_text(body, encoding="utf-8") + print(f"สร้างแล้ว: {dest.relative_to(ROOT.parent)}") + return 0 + + +def cmd_move(args) -> int: + if args.status not in STATUSES: + raise SystemExit(f"status ต้องเป็นหนึ่งใน {STATUSES}") + + target = None + for status, path, fm in iter_tasks(): + if fm.get("id") == args.id: + target = (status, path, fm) + break + if target is None: + raise SystemExit(f"ไม่พบงาน id = {args.id}") + + status, path, fm = target + if status == args.status: + print(f"{args.id} อยู่ใน {args.status} แล้ว") + return 0 + + dest = ROOT / args.status / path.name + shutil.move(str(path), str(dest)) + write_front_matter_value(dest, "status", args.status) + write_front_matter_value(dest, "updated", dt.date.today().isoformat()) + print(f"{args.id}: {status} -> {args.status}") + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(prog="tasks", description="Task tracker") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("list", help="รายการงานทั้งหมด").set_defaults(func=cmd_list) + sub.add_parser("report", help="สรุปภาพรวม").set_defaults(func=cmd_report) + + p_new = sub.add_parser("new", help="สร้างงานใหม่") + p_new.add_argument("title") + p_new.set_defaults(func=cmd_new) + + p_move = sub.add_parser("move", help="ย้ายสถานะงาน") + p_move.add_argument("id") + p_move.add_argument("status") + p_move.set_defaults(func=cmd_move) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())