task: Weekly review proposes task new/task close; tracker gains a closing write path
CI / verify (push) Successful in 54s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- INSTALL.md
- README.md
- VERSION
- docs/knowledge-and-commitment.md
- instructions/gtd-weekly-review/SKILL.md
- instructions/ingest-large-tree.md
- instructions/wiki-ingest/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
This commit is contained in:
torben committed 2026-09-22 21:46:09 +02:00
1 parent 8b535b4016
commit 62d1c5e636
17 files changed
+563 -87

No files matched your search

+8 -2
View File
@@ -39,7 +39,8 @@ def render_report(report: ReviewReport) -> str:
lines.append("No findings.")
else:
for finding in report.findings:
lines.append(f"[{finding.check}] {finding.project}: {finding.message}")
suffix = f" (id: {finding.item_id})" if finding.item_id is not None else ""
lines.append(f"[{finding.check}] {finding.project}: {finding.message}{suffix}")
lines.append("")
lines.append(f"{len(report.findings)} finding(s), {len(report.checks_run)}/{len(ALL_CHECKS)} check(s) ran.")
@@ -52,7 +53,12 @@ def report_to_dict(report: ReviewReport) -> dict:
has to parse prose to tell a partial report from a complete one."""
return {
"findings": [
{"check": finding.check, "project": finding.project, "message": finding.message}
{
"check": finding.check,
"project": finding.project,
"message": finding.message,
"item_id": finding.item_id,
}
for finding in report.findings
],
"checks_run": list(report.checks_run),
+90 -16
View File
@@ -1,20 +1,28 @@
"""`wikitool task new` - create a tracker item, no kb/ page (Gitea #132, #119
D1/D2/D4/D5/D6/D9).
"""`wikitool task new`/`task list`/`task close` - the tracker item write and
read surface outside `new project` (Gitea #132, #138; #119 D1/D2/D4/D5/D6/D9).
The second write path into the task tracker, alongside `new project`'s own
(`chemenu.commands.new_page._ensure_tracker_project`) - and the last one that
pairing needed, per `docs/knowledge-and-commitment.md`. Unlike `new project`
this never touches `kb/`: an ingest that finds both knowledge and a
commitment in one source runs this command for the commitment and the normal
page-creation commands (`new source`, ...) for the knowledge, as two
independent steps a skill sequences - never as one transaction, because
nothing here shares state with the page-creation path the way `new project`'s
own tracker-then-page order does within a single command.
`task new` is the second write path into the task tracker, alongside `new
project`'s own (`chemenu.commands.new_page._ensure_tracker_project`) - and
the last creation command that pairing needed, per
`docs/knowledge-and-commitment.md`. `task close` (#138) is the one closing
write: never a delete, only "mark done" (`TaskWriter.close_item`) - see that
protocol method's docstring and `docs/knowledge-and-commitment.md` for why
the surface stops there. `task list` (#138) is the read half a caller needs
to get an item's id before it can close it, without first running
`wikitool review`. None of the three ever touch `kb/`: an ingest that finds
both knowledge and a commitment in one source runs the tracker command for
the commitment and the normal page-creation commands (`new source`, ...) for
the knowledge, as two independent steps a skill sequences - never as one
transaction, because nothing here shares state with the page-creation path
the way `new project`'s own tracker-then-page order does within a single
command.
This module owns only the CLI shape - parsing, the `--project`/`--inbox`
exclusivity (#132 D4), and the `--follow-up-at` date. The one write itself is
`chemenu.tasks.protocol.TaskWriter.create_item`, dispatched through
`chemenu.tasks.build_writer` exactly like `new project` does.
exclusivity (#132 D4), the `--follow-up-at` date, and `task list`/`task
close`'s rendering. The writes themselves are
`chemenu.tasks.protocol.TaskWriter.create_item`/`close_item`, dispatched
through `chemenu.tasks.build_writer` exactly like `new project` does; the
read is `TaskReader.open_items`, the same call `chemenu.review` makes.
"""
from __future__ import annotations
@@ -29,8 +37,8 @@ from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
app = typer.Typer(
help="Create an item in the task tracker (Gitea #132) - never a kb/ page, "
"see `new project` for that pairing."
help="Create, list, and close items in the task tracker (Gitea #132, #138) - "
"never a kb/ page, see `new project` for that pairing."
)
@@ -121,3 +129,69 @@ def task_new_command(
where = "the tracker's inbox" if inbox else f"project '{project}'"
success(f"Created '{title}' in {where}.")
@app.command("list")
def task_list_command(
project: str = typer.Option(
..., "--project", help="An existing tracker project's name (matched case-insensitively)."
),
):
"""List a project's open items - id, title, and WAITING status (Gitea
#138) - so a caller can get an item's id for `task close` without first
running `wikitool review`. Read-only; works against either access mode a
provider offers."""
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nothing to list."
)
reader = tasks.build_reader(cfg)
try:
items = reader.open_items(project).items
except ValidationError as exc:
fail(str(exc))
return
if not items:
typer.echo(f"No open items in project '{project}'.")
return
for item in items:
marker = " [WAITING]" if item.waiting else ""
typer.echo(f"{item.id}\t{item.title}{marker}")
@app.command("close")
def task_close_command(
item_id: str = typer.Option(
...,
"--id",
help="The tracker's own item id (Gitea #138), e.g. from `task list` or `wikitool "
"review`'s waiting_overdue/someday_stale findings - never a title.",
),
):
"""Mark one tracker item done (Gitea #138) - never delete it. The only
closing write this stack makes; see
`chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/knowledge-and-commitment.md` for why."""
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nothing to close."
)
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
fail(str(exc))
try:
writer.close_item(item_id)
except ValidationError as exc:
fail(str(exc))
success(f"Closed item {item_id!r}.")
+12 -1
View File
@@ -48,11 +48,20 @@ class Finding:
"""One reported mismatch. `project` is the display name - the kb/ page's
title when a check is anchored on the kb/ side (checks 1 and 4, where the
kb/ page is what the finding is about), the tracker's own project name
otherwise (checks 2 and 3, where no kb/ page need exist)."""
otherwise (checks 2 and 3, where no kb/ page need exist).
`item_id` (Gitea #138) is the tracker's own id for the specific item a
finding is about - set on checks 2 (`waiting_overdue`) and 5
(`someday_stale`), which each name one item, and `None` on checks 1, 3
and 4, which are about a whole project rather than one item. It exists so
`gtd-weekly-review`'s own `task close --id` proposal (option (b) on both
checks) never has to re-look-up the item by title after the review
already read it."""
check: str
project: str
message: str
item_id: Optional[str] = None
@dataclass(frozen=True)
@@ -189,6 +198,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
CHECK_WAITING_OVERDUE, name,
f"'{item.title}' is {age_days} day(s) past its follow_up_at "
f"({item.follow_up_at.isoformat()}).",
item_id=item.id,
))
checks_run.append(CHECK_WAITING_OVERDUE)
@@ -249,6 +259,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
CHECK_SOMEDAY_STALE, item.title,
f"Untouched for {age_months} month(s) (last modified "
f"{item.modified.isoformat()}).",
item_id=item.id,
))
checks_run.append(CHECK_SOMEDAY_STALE)
+71 -21
View File
@@ -8,14 +8,22 @@ provider whose write path cannot exist (see `SuperProductivityWriter`) simply
does not implement `TaskWriter` - nothing here forces it to.
The read shape is fixed by what the weekly review (#119 D26, built in #125)
needs and nothing more: which projects exist and when they were created, how
many open items each one has and which of those are `WAITING` with a
`follow_up_at` (#119 D9/D30 - the *only* two machine-readable parts of a
waiting-for item; the person stays in the title's free text), and which
someday/maybe items exist and when they last moved. None of this is cached
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
never sees a value this process cached from before that step.
and `task list`/`task close` (#138) need and nothing more: which projects
exist and when they were created, every open item each one has - id, title,
and whether it is `WAITING` with a `follow_up_at` (#119 D9/D30 - the *only*
two machine-readable parts of a waiting-for item; the person stays in the
title's free text) - and which someday/maybe items exist and when they last
moved. None of this is cached here - a `TaskReader` re-reads on every call,
so a caller checking `verify()` after a human's out-of-band step (see
`chemenu.errors.HumanInterventionRequired`) never sees a value this process
cached from before that step.
An item's own id (#138) is read-only data, like everything else here - it is
never stored by `wikitool`, only ever passed straight back into
`TaskWriter.close_item` within the same invocation. That keeps the "one name
is the only coupling" decision (`docs/knowledge-and-commitment.md` § "One
name, carrying the duties of an identifier") intact: no id-to-anything
mapping is ever written down, so there is nothing to keep in sync.
**Re-reading is not the same as reading the current state** (Gitea #134,
resolved by #133's design rather than by a fix here): a point-in-time source
@@ -49,12 +57,15 @@ class ProjectSummary:
class WaitingItem:
"""One open item carrying the `WAITING` status (#119 D9/D30).
`title` is shown verbatim, person and all - the review never parses it.
`follow_up_at` is the one machine-readable date, and it is deliberately
**not** the item's due date (#119 D9: "ausdruecklich nicht das
Faelligkeitsdatum") - a provider that has no separate concept for this
must not fall back to reusing the due date, it must decide it cannot
supply the field and leave it `None` instead.
`id` is the provider's own item id (#138) - read-only, never guessed,
passed straight into `TaskWriter.close_item` when the review's
`waiting_overdue` (b) is confirmed. `title` is shown verbatim, person and
all - the review never parses it. `follow_up_at` is the one
machine-readable date, and it is deliberately **not** the item's due date
(#119 D9: "ausdruecklich nicht das Faelligkeitsdatum") - a provider that
has no separate concept for this must not fall back to reusing the due
date, it must decide it cannot supply the field and leave it `None`
instead.
**This rule binds the concept, not a field's name** (Gitea #135's own
correction, after #124's Super Productivity adapter read the wrong field
@@ -67,6 +78,7 @@ class WaitingItem:
counted as `follow_up_at` at all.
"""
id: str
title: str
follow_up_at: Optional[date]
@@ -85,21 +97,40 @@ class ReadSource:
detail: str
@dataclass(frozen=True)
class OpenItem:
"""One open item as `task list` (#138) needs it - id, title, and whether
it carries the `WAITING` status. Deliberately thinner than `WaitingItem`
(no `follow_up_at`): a waiting item still appears here, just without the
one field only the waiting-overdue check reads."""
id: str
title: str
waiting: bool
@dataclass(frozen=True)
class OpenItems:
"""A project's momentary open-loop count, plus the subset that is
`WAITING`. `count` includes the waiting items - it is "how many open
items", not "how many open items that aren't waiting"."""
"""A project's momentary open-loop count, the subset that is `WAITING`,
and the full list `task list` prints. `count` includes the waiting items
- it is "how many open items", not "how many open items that aren't
waiting". `items` and `waiting` overlap by design: a `WaitingItem` is
also present in `items`, since `task list` shows every open item
regardless of status."""
count: int
waiting: Sequence[WaitingItem]
items: Sequence[OpenItem]
@dataclass(frozen=True)
class SomedayItem:
"""One someday/maybe item: its title and when it last changed, for
"""One someday/maybe item: its id, title and when it last changed. `id`
is the provider's own item id (#138), passed into `TaskWriter.close_item`
when the review's `someday_stale` (b) is confirmed. `modified` feeds
check 5's staleness read (#119 D26)."""
id: str
title: str
modified: Optional[date]
@@ -125,9 +156,10 @@ class TaskReader(Protocol):
def open_items(self, project_name: str) -> OpenItems:
"""Open items for the project named `project_name` (matched
case-normalized, #119 D8). A project the tracker does not know
returns `OpenItems(count=0, waiting=())` - "no open items" and "no
such project" are not distinguished here, because check 3 (#119 D26)
is what tells those apart, over the read path's `projects()` list."""
returns `OpenItems(count=0, waiting=(), items=())` - "no open items"
and "no such project" are not distinguished here, because check 3
(#119 D26) is what tells those apart, over the read path's
`projects()` list."""
...
def someday_items(self) -> list[SomedayItem]:
@@ -206,6 +238,24 @@ class TaskWriter(Protocol):
"""
...
def close_item(self, item_id: str) -> None:
"""Mark the item `item_id` done - never delete it (Gitea #138). This
is the only closing write this stack ever makes: no "remove", no
"move the reminder forward". `item_id` is the provider's own id
(`WaitingItem.id`/`SomedayItem.id`/`OpenItem.id`), read fresh
immediately before the call and never guessed or looked up by title -
the tracker-side identity is opaque and provider-defined, unlike the
project name (#119 D8), which is why this takes an id rather than a
title the way `create_item` takes a project name.
Raises `chemenu.errors.ValidationError` if no item with this id
exists right now - nothing is written. Like `create_item`, never
raises `chemenu.errors.HumanInterventionRequired`: every provider
offering `TaskWriter` has a real per-item write call, the same gap
`create_project` alone hits.
"""
...
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
"""The project matching `name` case-normalized (#119 D8), or `None`.
+65 -8
View File
@@ -82,6 +82,32 @@ regardless of `access`; see its docstring and
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
`access: "snapshot"` the tracker is read-only from here by construction, not
by an extra check bolted onto this module (Gitea #133).
## Closing an item: `PATCH /tasks/:id` with `isDone: true`, nothing else
Verified against `super-productivity/super-productivity`'s `master` branch
(Gitea #138, 2026-09-22): `local-rest-api-handler.service.ts` routes
`PATCH /tasks/:id` through `pickAllowedFields`/`validateWritableFields` and
then a single `this._taskService.update(taskId, changes)` call - the exact
path `TaskService.setDone(id)` itself takes
(`update(id, { isDone: true })`), with no special-casing of `isDone` in
either the service or the task reducer. Concretely:
- `isDone` is in `ALLOWED_TASK_FIELDS`, so the route accepts it.
- Marking a task done through this API is **bit-identical** to the UI's own
checkbox: neither sets `doneOn` or any other field - `TaskCopy.doneOn`
exists on the model but nothing in `setDone`'s own call path writes it, so
a task closed here looks exactly like one a human clicked done on, not a
half-written state with a missing timestamp the UI would have set.
- An unknown task id makes the same handler return `404 TASK_NOT_FOUND`
before any write happens, which this module's `_ApiClient` already turns
into an ordinary `ValidationError` - no separate existence preflight is
needed for `close_item` to write nothing on a bad id.
`DELETE /tasks/:id` also exists on this API but is never called by this
module (Gitea #138 E7): a tracker item this adapter can create, it can only
ever mark done, never remove - the reversible half of the write surface, not
the irreversible one.
"""
from __future__ import annotations
@@ -95,6 +121,7 @@ from typing import Any, Optional
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import (
OpenItem,
OpenItems,
ProjectSummary,
ReadSource,
@@ -341,20 +368,27 @@ class SuperProductivityReader:
None,
)
if project is None:
return OpenItems(count=0, waiting=())
return OpenItems(count=0, waiting=(), items=())
waiting: list[WaitingItem] = []
all_items: list[OpenItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if _is_waiting(task, tags):
task_id_str = str(task.get("id", task_id))
task_title = str(task.get("title", ""))
is_waiting = _is_waiting(task, tags)
if is_waiting:
waiting.append(
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
WaitingItem(
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
)
)
return OpenItems(count=count, waiting=tuple(waiting))
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
def someday_items(self) -> list[SomedayItem]:
_, projects, tasks, _ = self._read()
@@ -366,6 +400,7 @@ class SuperProductivityReader:
continue
items.append(
SomedayItem(
id=str(task.get("id", task_id)),
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
@@ -418,6 +453,9 @@ class _ApiClient:
def post(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
return self._request("POST", path, body=body, timeout=timeout)
def patch(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
return self._request("PATCH", path, body=body, timeout=timeout)
def _request(
self, method: str, path: str, *, body: Optional[dict] = None, timeout: float = 10.0
) -> Any:
@@ -494,20 +532,27 @@ class SuperProductivityApiReader:
(p for p in projects if normalize_project_name(str(p.get("title", ""))) == target), None
)
if project is None:
return OpenItems(count=0, waiting=())
return OpenItems(count=0, waiting=(), items=())
waiting: list[WaitingItem] = []
all_items: list[OpenItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if _is_waiting(task, tags_by_id):
task_id_str = str(task.get("id", task_id))
task_title = str(task.get("title", ""))
is_waiting = _is_waiting(task, tags_by_id)
if is_waiting:
waiting.append(
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
WaitingItem(
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
)
)
return OpenItems(count=count, waiting=tuple(waiting))
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
def someday_items(self) -> list[SomedayItem]:
projects, tasks_by_id, _ = self._read()
@@ -519,6 +564,7 @@ class SuperProductivityApiReader:
continue
items.append(
SomedayItem(
id=str(task.get("id", task_id)),
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
@@ -606,6 +652,17 @@ class SuperProductivityWriter:
self._client.post("/tasks", body)
def close_item(self, item_id: str) -> None:
"""`PATCH /tasks/:id` with `{"isDone": true}` (Gitea #138) - see the
module docstring's "Closing an item" section for why this one field
is bit-identical to the UI's own done checkbox and why no existence
preflight is needed: an unknown `item_id` makes the same route
return `404 TASK_NOT_FOUND` before writing anything, which
`_ApiClient._request` already turns into a `ValidationError`. Never
sends `DELETE` - see #138 E7, marking done is the only closing write
this stack makes."""
self._client.patch(f"/tasks/{item_id}", {"isDone": True})
def _project_id(self, project_name: str) -> str:
"""Super Productivity's own id for `project_name`, read fresh from the
API. `ProjectSummary` (the protocol-level read shape every provider
+45
View File
@@ -148,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
assert fired == should_fire
def test_check2_finding_carries_the_waiting_items_own_id(tmp_path):
"""Gitea #138 - `gtd-weekly-review`'s `task close --id` proposal reads
this off the finding rather than re-looking the item up by title."""
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, 1, 1)}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
finding = next(f for f in report.findings if f.check == CHECK_WAITING_OVERDUE)
assert finding.item_id == "t1"
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
@@ -252,6 +269,34 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
assert fired == should_fire
def test_check5_finding_carries_the_someday_items_own_id(tmp_path):
"""Gitea #138 - the same id the `task close --id` proposal needs."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
"tagIds": [], "updated": _ms(2025, 1, 1)}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
finding = next(f for f in report.findings if f.check == CHECK_SOMEDAY_STALE)
assert finding.item_id == "t3"
def test_findings_with_no_specific_item_carry_no_item_id(tmp_path):
"""Checks 1, 3 and 4 are about a whole project, not one item (Gitea
#138) - their findings must not invent an id there is none for."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
report = run_review(tmp_path, today=TODAY)
assert report.findings
assert all(f.item_id is None for f in report.findings)
# --- provider/configuration errors -------------------------------------------
+56 -1
View File
@@ -281,6 +281,7 @@ def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
result = reader.open_items("Ship Chemenu 7.0")
assert len(result.waiting) == 1
waiting = result.waiting[0]
assert waiting.id == "t1"
assert waiting.title == "Warte auf Angebot vom Elektriker - Tobias"
assert waiting.follow_up_at == date(2026, 3, 1)
@@ -295,12 +296,26 @@ def test_open_items_unknown_project_is_empty_not_an_error(cfg):
result = reader.open_items("No Such Project")
assert result.count == 0
assert result.waiting == ()
assert result.items == ()
def test_open_items_items_carries_id_title_and_waiting_for_every_open_item(cfg):
"""Gitea #138 - `task list` reads this field, and it must agree with
`waiting`: every waiting item also appears here, marked `waiting=True`."""
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0")
by_id = {item.id: item for item in result.items}
assert len(result.items) == result.count
assert by_id["t1"].title == "Warte auf Angebot vom Elektriker - Tobias"
assert by_id["t1"].waiting is True
assert by_id["t2"].waiting is False
def test_someday_items_come_from_backlog_task_ids_only(cfg):
reader = sp.SuperProductivityReader(cfg)
items = reader.someday_items()
assert len(items) == 1
assert items[0].id == "t3"
assert items[0].title == "Irgendwann Keller aufraeumen"
assert items[0].modified == date(2026, 1, 15)
@@ -510,7 +525,9 @@ def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_fi
result = reader.open_items("Ship Chemenu 7.0")
assert result.count == 2 # t1, t2 - not the subtask t2b
assert len(result.waiting) == 1
assert result.waiting[0].id == "t1"
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
assert {item.id for item in result.items} == {"t1", "t2"}
def test_api_reader_someday_items():
@@ -518,7 +535,7 @@ def test_api_reader_someday_items():
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
items = reader.someday_items()
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
assert [(i.id, i.title) for i in items] == [("t3", "Irgendwann Keller aufraeumen")]
def test_api_reader_401_without_the_right_token_fails_loud():
@@ -606,6 +623,23 @@ def _make_write_handler(state: dict, *, token: str = "test-token"):
return
self._reply(404, {"error": "not found"})
def do_PATCH(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path.startswith("/tasks/"):
task_id = self.path[len("/tasks/"):]
known_ids = {t["id"] for t in state.get("tasks", [])}
if task_id not in known_ids:
self._reply(404, {"code": "TASK_NOT_FOUND", "message": "Task not found"})
return
state.setdefault("patched", []).append((task_id, payload))
self._reply(200, {"id": task_id, **payload})
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
@@ -714,6 +748,27 @@ def test_create_item_waiting_without_the_tag_refuses_and_posts_nothing():
assert "posted" not in state
# --- write path: close_item (Gitea #138) ---------------------------------------
def test_close_item_patches_is_done_true_and_nothing_else():
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.close_item("t1")
assert state["patched"] == [("t1", {"isDone": True})]
def test_close_item_unknown_id_refuses_and_writes_nothing():
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError):
writer.close_item("no-such-id")
assert "patched" not in state
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
+101
View File
@@ -72,6 +72,24 @@ def _make_handler(state: dict, *, token: str = _API_TOKEN):
return
self._reply(404, {"error": "not found"})
def do_PATCH(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path.startswith("/tasks/"):
task_id = self.path[len("/tasks/"):]
record = next((t for t in state.get("tasks", []) if t.get("id") == task_id), None)
if record is None:
self._reply(404, {"code": "TASK_NOT_FOUND", "message": "Task not found"})
return
record.update(payload)
state.setdefault("patched", []).append((task_id, payload))
self._reply(200, record)
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
@@ -278,3 +296,86 @@ def test_snapshot_access_has_no_write_path(monkeypatch, kb_dir, tmp_path):
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
# --- `task list` (Gitea #138) -------------------------------------------------
def test_list_shows_id_title_and_waiting_marker(monkeypatch, kb_dir):
root = kb_dir.parent
state = {
"projects": [{**_project_record(), "taskIds": ["t1", "t2"]}],
"tasks": [
{"id": "t1", "title": "Nachfassen beim Elektriker", "isDone": False,
"tagIds": ["tag-wait"]},
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
],
"tags": [{"id": "tag-wait", "title": "waiting"}],
}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "ship CHEMENU 7.0"])
assert result.exit_code == 0, result.output
assert "t1\tNachfassen beim Elektriker [WAITING]" in result.output
assert "t2\tKickoff-Meeting vorbereiten" in result.output
assert "t2\tKickoff-Meeting vorbereiten [WAITING]" not in result.output
def test_list_empty_project_says_so(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": [_project_record()], "tasks": []}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
assert result.exit_code == 0, result.output
assert "No open items" in result.output
# --- `task close` (Gitea #138) ------------------------------------------------
def test_close_marks_the_item_done(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
assert result.exit_code == 0, result.output
assert state["patched"] == [("t1", {"isDone": True})]
def test_close_unknown_id_is_refused_and_writes_nothing(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"tasks": []}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "no-such-id"])
assert result.exit_code == 1
assert "patched" not in state
def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatch, kb_dir):
"""Gitea #138 E7/#133: closing is a write like creating - `access:
'snapshot'` never offers a `TaskWriter`, so `task close` refuses with
the exact message `task new` already gives on the same configuration."""
root = kb_dir.parent
backups_dir = root / "backups"
backups_dir.mkdir()
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": {"ids": [], "entities": {}},
"task": {"ids": [], "entities": {}},
"tag": {"ids": [], "entities": {}},
}),
encoding="utf-8",
)
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
assert result.exit_code == 1
assert "access: 'api'" in result.output