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
@@ -96,6 +96,8 @@ tools/wikitool <command> --help
|
||||
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
|
||||
| `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider whose *configured access path* has no write path (Super Productivity's `access: "snapshot"` - the tracker is read-only from there by construction) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - neither the tracker project nor the page is created, and `--resume` behaves the same. A provider that could write but has no project-creation endpoint of its own (Super Productivity's `access: "api"` - `GET /projects` exists, `POST /projects` does not) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused |
|
||||
| `task new --title "<Title>" (--project "<Name>" \| --inbox) [--waiting [--follow-up-at YYYY-MM-DD]] [--notes "..."]` | Create one open item in the configured task tracker - never a kb/ page. The second creation command alongside `new project`, and the last one their split needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing tracker project, matched case-insensitively - never created and never searched or guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item filed there never appears in `review`, since every one of its checks is reached through a project name and the inbox has none) is required; an omitted `--project` refuses rather than silently falling into the inbox. `--waiting` sets the WAITING status the review's own waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source page this item came from), stored verbatim, never parsed - the same posture a `WAITING` item's own title already has for the person named in it. No `.wikitool-tasks.json` fails immediately with the same "no tracker configured" message as `review`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `new project`. Unlike `new project`, **never exits 42**: every provider offering a write path at all has a real item-creation call (Super Productivity's `POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not match any tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be created via its API), are ordinary exit-1 refusals instead, creating nothing |
|
||||
| `task list --project "<Name>"` | List a project's open items - id, title, and whether each carries the `WAITING` status. Read-only; the id source `task close` and the review's own `waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. Works on either access mode a provider offers, unlike the write commands below. No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `review`/`task new`; a `--project` matching no tracker project prints "No open items", since `TaskReader.open_items` does not distinguish "empty" from "unknown" (`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring) |
|
||||
| `task close --id <item-id>` | Mark one tracker item done - never delete it, per `docs/knowledge-and-commitment.md`. `<item-id>` is the provider's own id, from `task list` or a `review` finding, never a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and the tracker's only shared coupling. The only closing write this stack makes: no "move a reminder", no "remove an item". No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `task new`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `task new`. Never exits 42, same reasoning as `task new`: every provider offering a write path has a real per-item write call |
|
||||
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
|
||||
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
|
||||
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
|
||||
@@ -313,6 +315,8 @@ is atomic, and whether a retry is safe.
|
||||
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
|
||||
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above: the provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it |
|
||||
| `task new` | No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching no tracker project, `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist), or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind | Not transient; fix the argument, create the missing tracker project or tag first, or point at an `access: "api"` instance, then retry once. **Never exit 42** - unlike `new project`, every provider offering a write path at all has a real item-creation call, so there is no human-clearance step to wait on here |
|
||||
| `task list` | No `.wikitool-tasks.json` | Yes - read-only, nothing to leave half-written | Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row |
|
||||
| `task close` | No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written | Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` |
|
||||
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
|
||||
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
|
||||
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
|
||||
|
||||
@@ -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