task: Weekly review proposes task new/task close; tracker gains a closing write path
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:
1 parent
8b535b4016
commit
62d1c5e636
17 files changed
+563
-87
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user