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
@@ -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