build: wikitool new project - Seite und Tracker-Projekt unter einem Namen (#126)
Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/new_page.py - tools/chemenu/errors.py - tools/chemenu/review.py - tools/chemenu/tasks/__init__.py - tools/chemenu/tests/test_new_page.py
This commit is contained in:
1 parent
80b57e0d01
commit
e4260fc2de
8 files changed
+342
-24
No files matched your search
+33
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.0-beta.3 - 2026-09-19 - wikitool review: the weekly GTD review as a read-time join
|
||||
## 7.0.0-beta.4 - 2026-09-20 - wikitool new project: Seite und Tracker-Projekt unter einem Namen
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
@@ -71,6 +71,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
||||
- Task-tracker provider layer, with a Super Productivity adapter
|
||||
- wikitool review: the weekly GTD review as a read-time join
|
||||
- wikitool new project: Seite und Tracker-Projekt unter einem Namen
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
||||
@@ -162,6 +163,37 @@ ist ein Konfigurationsfehler, kein Erreichbarkeitsproblem, und braucht deshalb k
|
||||
`kb_project_count`/`complete`); ein Test haelt beide Formen gegeneinander, wie es
|
||||
`test_mcp_server.py` fuer den MCP-Lesepfad gegen die CLI tut.
|
||||
|
||||
### wikitool new project: Seite und Tracker-Projekt unter einem Namen
|
||||
|
||||
Gitea #119 (Paket #126): `wikitool new project --name X --set responsibility=Y` legt jetzt, wenn
|
||||
`.wikitool-tasks.json` einen Tracker konfiguriert, zusaetzlich ein gleichnamiges Tracker-Projekt
|
||||
an - ein Geburtsort, ein Name (D8/D31). Tracker vor Seite: erst steht die Tracker-Seite fest,
|
||||
erst danach wird die `kb/`-Seite geschrieben, damit ein Fehlschlag zwischen beiden immer im
|
||||
selben, bereits bekannten Zustand landet - "Tracker-Projekt ohne Seite", das `review`s Pruefung 3
|
||||
ohnehin meldet - nie im unbekannten "Seite ohne Tracker-Projekt". Ist kein Tracker konfiguriert,
|
||||
bleibt es bei der reinen Seitenanlage, jetzt aber ausdruecklich als solche vermerkt statt
|
||||
stillschweigend.
|
||||
|
||||
`chemenu.tasks.build_reader`/`build_writer` (neu in `chemenu/tasks/__init__.py`) sind die eine
|
||||
Dispatch-Tabelle von `TasksConfig.provider` auf einen konkreten Adapter, jetzt von `review.py`
|
||||
*und* `new_page.py` geteilt statt zweimal derselben `if cfg.provider == "superproductivity"`.
|
||||
|
||||
Fuer einen Provider ohne Schreibpfad (Super Productivity, #124: keine `POST /projects`) wirft
|
||||
`create_project` `chemenu.errors.HumanInterventionRequired` - das Kommando zeigt die Anweisung
|
||||
und beendet sich mit Exit 42, ohne irgendetwas anzulegen. Die offene Frage aus #126s eigenem
|
||||
Issue-Text war, wie ein zustandsloser CLI-Prozess bei einem erneuten Aufruf eine echte
|
||||
Namenskollision von "der Mensch hat gerade getan, worum genau dieses Kommando gebeten hat"
|
||||
unterscheidet - beides sieht am Lesepfad identisch aus (Tracker hat den Namen, `kb/` noch keine
|
||||
Seite). Entschieden (mit dem Betreiber, nicht allein): ein explizites `--resume`, das ein Treffer
|
||||
im Tracker als bestaetigte Fortsetzung liest statt als Kollision - ohne `--resume` bleibt jeder
|
||||
Treffer eine Ablehnung samt Fundort, auch bei einem Wiederholungsaufruf. `--resume` ohne einen
|
||||
tatsaechlich fehlenden Tracker-Eintrag wirft dieselbe `HumanInterventionRequired`-Meldung erneut,
|
||||
keine stille Weiterarbeit auf Zuruf. `--resume` bei jedem anderen Typ wird abgelehnt.
|
||||
|
||||
Ein erzwungener Fehlschlag der eigentlichen Seiten-Schreibaktion (Schritt 3) nach bereits
|
||||
bestaetigtem Tracker-Projekt ist eigens getestet: die Meldung nennt, dass die Tracker-Seite schon
|
||||
steht und nur die `kb/`-Seite fehlt, nie umgekehrt.
|
||||
|
||||
---
|
||||
|
||||
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
|
||||
|
||||
@@ -94,6 +94,7 @@ tools/wikitool <command> --help
|
||||
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
||||
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
||||
| `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 with no write path of its own (Super Productivity has no project-creation endpoint) 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 |
|
||||
| `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 |
|
||||
@@ -309,6 +310,7 @@ is atomic, and whether a retry is safe.
|
||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||
|---------|--------------|---------|--------------|
|
||||
| `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), or `--resume` was passed for a type other than `project` | **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 or a bad `--set` is not transient, same as `new <type>`. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own third outcome: the provider 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 |
|
||||
| `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 |
|
||||
|
||||
@@ -28,18 +28,29 @@ import re
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, tasks
|
||||
from chemenu.commands._util import (
|
||||
check_collision,
|
||||
check_raw_files_exist,
|
||||
fail,
|
||||
needs_clearance,
|
||||
parse_set_fields,
|
||||
rel_path,
|
||||
success,
|
||||
)
|
||||
from chemenu.errors import HumanInterventionRequired, ValidationError
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.tasks import config as tasks_config
|
||||
from chemenu.tasks.protocol import find_project
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
# The one type name for which `new` also touches the task tracker (Gitea
|
||||
# #126, #119 D8/D16/D31) - the same literal `chemenu.review._load_kb_projects`
|
||||
# already matches `page.kind` against, and the one `docs verify`'s
|
||||
# `check_stack_required_types` (`kb_collections.STACK_REQUIRED_TYPES`) makes
|
||||
# sure some type-spec actually declares `name: project`.
|
||||
PROJECT_TYPE_NAME = "project"
|
||||
|
||||
|
||||
def _default_summary(summary: str) -> str:
|
||||
"""Scaffold-time placeholder for an unfilled --summary, so schema
|
||||
@@ -255,6 +266,57 @@ def _load_type_or_fail(type_path: str, source_dir: Path):
|
||||
fail(str(exc))
|
||||
|
||||
|
||||
def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
|
||||
"""Step 1+2 of `new project` (Gitea #126, #119 D8/D31): make sure a
|
||||
tracker project named `page_title` exists before the caller writes the
|
||||
kb/ page for it, and never touch the page itself.
|
||||
|
||||
Returns a one-line status to fold into the success message, or `None`
|
||||
when no tracker is configured at all - `page_title` is the page's actual
|
||||
title (title_prefix already applied), the same value `check_collision`
|
||||
checked against `kb/` moments earlier, because it is what the join in
|
||||
`chemenu.review` keys on (#119 D8: the name is the sole coupling).
|
||||
|
||||
Leaves through `fail()`/`needs_clearance()` (never returns) for every
|
||||
outcome that must not proceed to page creation - a collision, a
|
||||
dependency failure, or a human still owing the manual step - so that by
|
||||
the time this returns normally, either nothing was created (page-only or
|
||||
a genuine refusal) or a tracker project now provably exists for this
|
||||
exact name, and the caller's next step is the only one left: write the
|
||||
page.
|
||||
"""
|
||||
cfg = tasks_config.read_config(config.ROOT)
|
||||
if cfg is None:
|
||||
return None
|
||||
|
||||
reader = tasks.build_reader(cfg)
|
||||
existing = find_project(reader, page_title)
|
||||
if existing is not None:
|
||||
if resume:
|
||||
return f"tracker project '{existing.name}' already existed (--resume)"
|
||||
fail(
|
||||
f"A project named '{page_title}' (case-insensitively) already exists in the "
|
||||
f"tracker ('{existing.name}') - nothing was created (neither the tracker project "
|
||||
"nor the kb/ page). If an earlier run of this exact command asked you to create it "
|
||||
"by hand and you just did, re-run with --resume to continue to page creation "
|
||||
"instead of being refused here."
|
||||
)
|
||||
|
||||
writer = tasks.build_writer(cfg, reader)
|
||||
try:
|
||||
writer.create_project(page_title)
|
||||
except HumanInterventionRequired as exc:
|
||||
needs_clearance(
|
||||
f"{exc}\n\nNothing was created yet for '{page_title}' (neither the tracker "
|
||||
"project nor the kb/ page). Once you have done the above, re-run this exact "
|
||||
"command with --resume to verify it and continue to page creation - do not assume "
|
||||
"confirming here is enough."
|
||||
)
|
||||
except ValidationError as exc:
|
||||
fail(str(exc))
|
||||
return f"tracker project '{page_title}' created"
|
||||
|
||||
|
||||
def new_page_command(
|
||||
type_name: str = typer.Argument(
|
||||
...,
|
||||
@@ -269,6 +331,13 @@ def new_page_command(
|
||||
"--set",
|
||||
help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing",
|
||||
),
|
||||
resume: bool = typer.Option(
|
||||
False,
|
||||
"--resume",
|
||||
help="`project` only: confirm a human has completed the manual tracker step an earlier "
|
||||
"HumanInterventionRequired refusal asked for, so this run continues to page creation "
|
||||
"instead of refusing the now-existing tracker project as a collision (Gitea #126).",
|
||||
),
|
||||
):
|
||||
"""Scaffold a new wiki page of any type.
|
||||
|
||||
@@ -277,12 +346,23 @@ def new_page_command(
|
||||
(schema `default:`), where the page is written (`base_dir` + `layout`),
|
||||
what prefixes its title (`title_prefix`), and its body skeleton (the
|
||||
type-spec's template). Adding a new type therefore needs no change here.
|
||||
|
||||
For `type_name == "project"` specifically, this also ensures a
|
||||
same-named tracker project exists (Gitea #126, #119 D8/D31) before the
|
||||
page is written - see `_ensure_tracker_project`.
|
||||
"""
|
||||
type_path = type_path_override or resolver.find_type_by_name(type_name)
|
||||
if not type_path:
|
||||
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
|
||||
fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}")
|
||||
|
||||
try:
|
||||
is_project = resolver.get_type_name(type_path) == PROJECT_TYPE_NAME
|
||||
except ValueError as exc:
|
||||
fail(str(exc))
|
||||
if resume and not is_project:
|
||||
fail("--resume only applies to `new project` (Gitea #126) - it has no effect on any other type.")
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
try:
|
||||
@@ -356,5 +436,27 @@ def new_page_command(
|
||||
},
|
||||
)
|
||||
|
||||
write_page(path, frontmatter, body)
|
||||
success(f"Created {rel_path(path)}")
|
||||
# Everything above only validates - nothing has touched disk or the
|
||||
# tracker yet. Tracker before page (Gitea #126's own "Reihenfolge ist die
|
||||
# Fehlerbehandlung"): a page-write failure past this point leaves a
|
||||
# tracker project with no page, a state check 3 (#125) already reports;
|
||||
# the reverse order would instead leave a kb/ page claiming an
|
||||
# initiative nobody can act on, which is worse and unreported.
|
||||
tracker_note = _ensure_tracker_project(page_title, resume=resume) if is_project else None
|
||||
|
||||
try:
|
||||
write_page(path, frontmatter, body)
|
||||
except OSError as exc:
|
||||
if is_project and tracker_note is not None:
|
||||
fail(
|
||||
f"Could not write {rel_path(path)} ({exc}). The kb/ page was NOT created, but "
|
||||
f"the tracker project was already confirmed to exist ({tracker_note}). Fix the "
|
||||
"write error and re-run with --resume to finish - a plain re-run would otherwise "
|
||||
"be refused as a tracker collision."
|
||||
)
|
||||
raise
|
||||
|
||||
msg = f"Created {rel_path(path)}"
|
||||
if is_project:
|
||||
msg += f" ({tracker_note or 'no task tracker configured - page only'})"
|
||||
success(msg)
|
||||
@@ -40,8 +40,8 @@ class HumanInterventionRequired(ChemenuError):
|
||||
`GET /projects`, so `SuperProductivityWriter.create_project` cannot do the
|
||||
one write `chemenu.tasks.protocol.TaskWriter` asks of it.
|
||||
|
||||
The CLI adapter (a future `wikitool new project`, Gitea #126) renders this
|
||||
the same way it renders the four named gates in AGENTS.md's Gates section:
|
||||
The CLI adapter (`wikitool new project`, Gitea #126) renders this the same
|
||||
way it renders the four named gates in AGENTS.md's Gates section:
|
||||
`commands._util.needs_clearance(str(exc))`, exit code 42 - "a human must
|
||||
see the command's output before anything proceeds" applies here for the
|
||||
same reason it applies to a mass update, just for a different cause. It is
|
||||
@@ -54,7 +54,11 @@ class HumanInterventionRequired(ChemenuError):
|
||||
human's out-of-band step actually landed. A caller must invoke it after
|
||||
the human confirms doing what `str(exc)` asked - "the user says they did
|
||||
it" is never treated as "it happened" - and must refuse to proceed (and
|
||||
ask again) while it still returns False.
|
||||
ask again) while it still returns False. `wikitool new project` is a
|
||||
fresh process each time rather than a long-lived caller holding onto this
|
||||
one `exc`, so it does not call `verify` itself - its `--resume` flag
|
||||
re-runs the equivalent read-path check (`chemenu.tasks.protocol.find_project`)
|
||||
from scratch instead, which answers the same question this closure would.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, verify):
|
||||
|
||||
+3
-14
@@ -25,8 +25,9 @@ from typing import Optional
|
||||
|
||||
from chemenu import config, kb_scan
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks import build_reader
|
||||
from chemenu.tasks import config as tasks_config
|
||||
from chemenu.tasks.protocol import OpenItems, TaskReader, normalize_project_name
|
||||
from chemenu.tasks.protocol import OpenItems, normalize_project_name
|
||||
|
||||
CHECK_STALLED = "stalled"
|
||||
CHECK_WAITING_OVERDUE = "waiting_overdue"
|
||||
@@ -91,18 +92,6 @@ def _load_kb_projects(kb_dir: Path) -> dict[str, _KbProject]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_reader(cfg: tasks_config.TasksConfig) -> TaskReader:
|
||||
"""Dispatch on `cfg.provider` - the same inline shape
|
||||
`doctor.check_tasks_provider` uses, kept in sync with
|
||||
`tasks_config.KNOWN_PROVIDERS`."""
|
||||
if cfg.provider == "superproductivity":
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
|
||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
||||
return sp.SuperProductivityReader(sp_cfg)
|
||||
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
|
||||
|
||||
|
||||
def _weeks_between(start: date, end: date) -> float:
|
||||
return (end - start).days / 7
|
||||
|
||||
@@ -136,7 +125,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
||||
"nothing to join the weekly review against. Configure one first."
|
||||
)
|
||||
reader = _build_reader(cfg)
|
||||
reader = build_reader(cfg)
|
||||
kb_projects = _load_kb_projects(Path(root) / "kb")
|
||||
|
||||
findings: list[Finding] = []
|
||||
|
||||
@@ -4,9 +4,43 @@
|
||||
loops (#119 D1). This package is the one place `wikitool` crosses that line -
|
||||
never an instruction, never a second MCP server (#119 D25): a provider is a
|
||||
Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and
|
||||
everything above this package (the future `wikitool review`/`new project`
|
||||
commands, #125/#126) talks to that protocol and nothing provider-specific.
|
||||
everything above this package (`wikitool review`/`new project`, #125/#126)
|
||||
talks to that protocol and nothing provider-specific.
|
||||
|
||||
No command lives here yet - this package is a library, per #124's own scope
|
||||
note ("Kein Kommando. Diese Schicht ist Bibliothek").
|
||||
|
||||
`build_reader`/`build_writer` below are the one dispatch table from
|
||||
`TasksConfig.provider` to a concrete adapter, shared by `chemenu.review`
|
||||
(#125) and `chemenu.commands.new_page`'s `project` handling (#126) - kept in
|
||||
one place per `AGENTS.md` invariant 8, rather than two copies of the same
|
||||
`if cfg.provider == "superproductivity": ...` drifting apart.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks.config import TasksConfig
|
||||
from chemenu.tasks.protocol import TaskReader, TaskWriter
|
||||
|
||||
|
||||
def build_reader(cfg: TasksConfig) -> TaskReader:
|
||||
"""Dispatch on `cfg.provider` to a concrete `TaskReader`."""
|
||||
if cfg.provider == "superproductivity":
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
|
||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
||||
return sp.SuperProductivityReader(sp_cfg)
|
||||
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
|
||||
|
||||
|
||||
def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
|
||||
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
|
||||
already-built `reader` - a writer that needs to re-check the read path
|
||||
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
|
||||
the same object its caller does, rather than opening a second one."""
|
||||
if cfg.provider == "superproductivity":
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
|
||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
||||
return sp.SuperProductivityWriter(sp_cfg, reader)
|
||||
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from chemenu.commands._util import coerce_set_value, parse_set_fields
|
||||
from chemenu.commands.new_page import _page_subdir
|
||||
from chemenu.frontmatter_io import read_page
|
||||
@@ -6,6 +8,47 @@ from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_TASKS_THRESHOLDS = {
|
||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
|
||||
}
|
||||
|
||||
|
||||
def _write_tasks_config(root, db_path) -> None:
|
||||
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
|
||||
provider at `db_path` - same shape `test_review.py`'s own helper writes,
|
||||
duplicated locally rather than imported since test files in this suite
|
||||
do not import each other (see `instructions/dev/testing-conventions.md`
|
||||
for the isolation this mirrors one layer up)."""
|
||||
(root / ".wikitool-tasks.json").write_text(
|
||||
json.dumps({
|
||||
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
||||
"superproductivity": {"db_path": str(db_path)},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_sp_snapshot(root, project_titles: list[str]):
|
||||
"""A Super Productivity backup snapshot whose `project` entity state
|
||||
holds one project per title in `project_titles` and nothing else -
|
||||
enough for `find_project`/`create_project`'s own preflight, which is all
|
||||
`new project`'s tracker step reads or writes (it never calls
|
||||
`open_items`/`someday_items`)."""
|
||||
db_path = root / "db.json"
|
||||
projects = {
|
||||
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
|
||||
for i, title in enumerate(project_titles)
|
||||
}
|
||||
db_path.write_text(
|
||||
json.dumps({
|
||||
"project": {"ids": list(projects), "entities": projects},
|
||||
"task": {"ids": [], "entities": {}},
|
||||
"tag": {"ids": [], "entities": {}},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return db_path
|
||||
|
||||
|
||||
def _invoke_new(monkeypatch, kb_dir, args):
|
||||
"""Invoke the CLI against a temporary fixture kb/.
|
||||
@@ -104,6 +147,118 @@ def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_d
|
||||
assert not list(kb_dir.rglob("Badvorhaben.md"))
|
||||
|
||||
|
||||
# --- Gitea #126: `new project` and the task tracker --------------------------
|
||||
|
||||
|
||||
def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch, kb_dir):
|
||||
"""No `.wikitool-tasks.json` at all - the page-only state is legitimate
|
||||
(#126's own AC) but must be said, not left implicit."""
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Ohne Tracker", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "no task tracker configured" in result.output
|
||||
assert (kb_dir / "gtd/haus/Ohne Tracker.md").exists()
|
||||
|
||||
|
||||
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
|
||||
"""Super Productivity can never create a project itself (Gitea #124) -
|
||||
the first attempt against a free name must exit 42 and create nothing on
|
||||
either side."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, [])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert "NEEDS USER CLEARANCE" in result.output
|
||||
assert "--resume" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
|
||||
"""The tracker already has this name (case-insensitively) and --resume
|
||||
was not passed - #126's AC: refuse, name the collision, create nothing."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
|
||||
"""The retry half of the HumanInterventionRequired dance (#126's own
|
||||
"Schritt 2"): once a human has created the tracker project by hand, a
|
||||
re-run with --resume must verify it via the read path and continue to
|
||||
page creation instead of treating it as a collision."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already existed" in result.output
|
||||
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
|
||||
"""--resume against a tracker that still does not have the project must
|
||||
read the same as a fresh attempt - the same HumanInterventionRequired
|
||||
message again, not a silent pass-through (#126's own wording: "wirft das
|
||||
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, [])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 42
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_resume_flag_is_refused_for_any_type_other_than_project(monkeypatch, kb_dir):
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "entity", "--name", "Irrelevant", "--set", "entity_type=tool", "--resume",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "--resume" in result.output
|
||||
assert not list(kb_dir.rglob("Irrelevant.md"))
|
||||
|
||||
|
||||
def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch, kb_dir):
|
||||
"""#126's own atomicity AC, forced: step 3 (the file write) fails after
|
||||
step 2 (the tracker side) already stands - here, an already-existing
|
||||
tracker project confirmed via --resume. The outcome must never be "only
|
||||
the page" - here it is neither, since the write itself never lands."""
|
||||
import chemenu.commands.new_page as new_page
|
||||
|
||||
root = kb_dir.parent
|
||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
||||
_write_tasks_config(root, db_path)
|
||||
|
||||
def _boom(path, frontmatter, body):
|
||||
raise OSError("disk full (fixture)")
|
||||
|
||||
monkeypatch.setattr(new_page, "write_page", _boom)
|
||||
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already confirmed to exist" in " ".join(result.output.split())
|
||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||
|
||||
|
||||
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
|
||||
"""Gitea #109 stops materializing an optional field's schema `default:`,
|
||||
but `tags`/`related`/`sources` are optional arrays with no `default:` at
|
||||
|
||||
Reference in new issue
Block a user