stack: wikitool review - der Wochenrueckblick als Join zur Lesezeit (#125)
Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/review.py - tools/chemenu/tests/test_review.py
This commit is contained in:
1 parent
1875449b31
commit
80b57e0d01
8 files changed
+751
-5
No files matched your search
@@ -0,0 +1,262 @@
|
||||
"""`wikitool review` core (Gitea #125; design in #119 D3/D8/D10/D26).
|
||||
|
||||
The weekly review joins the task tracker (`chemenu.tasks`, #124) against the
|
||||
`kb/gtd/` project pages over the case-normalized project name
|
||||
(`chemenu.tasks.protocol.normalize_project_name`, #119 D8) - a join done at
|
||||
*read time* and never stored (#119 D3, the `reports/` posture: never
|
||||
re-derive, always compile, but nothing here is a compiled artifact). This
|
||||
module owns the five checks and their data flow; `chemenu.commands.review_cmd`
|
||||
owns the CLI adapter, flags and rendering.
|
||||
|
||||
Every provider read is wrapped individually so a single unreachable call
|
||||
degrades the affected checks rather than the whole report: `projects()`
|
||||
failing skips checks 1/2/3/4 (they all need the project list), `someday_items()`
|
||||
failing skips only check 5, and a single project's `open_items()` failing
|
||||
skips that one project everywhere without aborting the others. A `ReviewReport`
|
||||
with anything in `checks_skipped` is, by construction, never mistaken for a
|
||||
quiet week - see `ReviewReport.complete` and `commands.review_cmd`'s exit code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, kb_scan
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks import config as tasks_config
|
||||
from chemenu.tasks.protocol import OpenItems, TaskReader, normalize_project_name
|
||||
|
||||
CHECK_STALLED = "stalled"
|
||||
CHECK_WAITING_OVERDUE = "waiting_overdue"
|
||||
CHECK_UNPAGED_PROJECT = "unpaged_project"
|
||||
CHECK_NO_OPEN_LOOP = "no_open_loop"
|
||||
CHECK_SOMEDAY_STALE = "someday_stale"
|
||||
|
||||
# The checks that need the full tracker project list (#119 D26 checks 1/2/3/4)
|
||||
# - `projects()` failing skips all four together, since none of them can be
|
||||
# answered from `someday_items()` alone.
|
||||
_PROJECT_LIST_CHECKS = (CHECK_STALLED, CHECK_WAITING_OVERDUE, CHECK_UNPAGED_PROJECT, CHECK_NO_OPEN_LOOP)
|
||||
|
||||
ALL_CHECKS = (*_PROJECT_LIST_CHECKS, CHECK_SOMEDAY_STALE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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)."""
|
||||
|
||||
check: str
|
||||
project: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewReport:
|
||||
findings: tuple[Finding, ...]
|
||||
checks_run: tuple[str, ...]
|
||||
checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
|
||||
kb_project_count: int
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Whether every one of the five checks actually ran. `False` is the
|
||||
signal `commands.review_cmd` exits 1 on - a partial report must never
|
||||
read like a quiet week (see this module's docstring)."""
|
||||
return not self.checks_skipped
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _KbProject:
|
||||
title: str
|
||||
state: Optional[str]
|
||||
|
||||
|
||||
def _load_kb_projects(kb_dir: Path) -> dict[str, _KbProject]:
|
||||
"""Every `kb/gtd/` project page, keyed by case-normalized title (#119 D8).
|
||||
`page.kind` resolves through the type-spec's own `name:` field
|
||||
(`Page.kind`), so this finds a `project` page regardless of which
|
||||
`responsibility:` area it lives under."""
|
||||
pages = kb_scan.load_kb_pages(kb_dir)
|
||||
result: dict[str, _KbProject] = {}
|
||||
for page in pages.values():
|
||||
if page.kind != "project":
|
||||
continue
|
||||
result[normalize_project_name(page.title)] = _KbProject(
|
||||
title=page.title, state=page.frontmatter.get("state")
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def _months_between(start: date, end: date) -> int:
|
||||
"""Whole calendar months between two dates, floored - `age_months` for
|
||||
check 5. Deliberately calendar-based rather than `days / 30`: an
|
||||
approximation would drift a fixed-days threshold away from what "N months"
|
||||
actually means at the boundary."""
|
||||
months = (end.year - start.year) * 12 + (end.month - start.month)
|
||||
if end.day < start.day:
|
||||
months -= 1
|
||||
return months
|
||||
|
||||
|
||||
def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
"""Run the five weekly-review checks (#119 D10/D26) against the instance
|
||||
rooted at `root` and return their findings. Stores nothing (#119 D3): every
|
||||
provider read is a fresh call, and no file under `root` is touched.
|
||||
|
||||
Raises `ValidationError` for anything that is not "the provider is
|
||||
unreachable right now" - no `.wikitool-tasks.json` at all, or a
|
||||
malformed one. Those are configuration problems a retry cannot fix, so
|
||||
`commands.review_cmd` reports them as an ordinary exit-1 error rather than
|
||||
a partial report.
|
||||
"""
|
||||
today = today if today is not None else date.today()
|
||||
cfg = tasks_config.read_config(root)
|
||||
if cfg is None:
|
||||
raise ValidationError(
|
||||
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)
|
||||
kb_projects = _load_kb_projects(Path(root) / "kb")
|
||||
|
||||
findings: list[Finding] = []
|
||||
checks_run: list[str] = []
|
||||
checks_skipped: list[tuple[str, str]] = []
|
||||
|
||||
try:
|
||||
tracker_projects = reader.projects()
|
||||
except ValidationError as exc:
|
||||
reason = str(exc)
|
||||
checks_skipped.extend((check, reason) for check in _PROJECT_LIST_CHECKS)
|
||||
tracker_projects = None
|
||||
|
||||
if tracker_projects is not None:
|
||||
open_items_by_key: dict[str, tuple[str, OpenItems]] = {}
|
||||
broken_keys: set[str] = set()
|
||||
for project in tracker_projects:
|
||||
key = normalize_project_name(project.name)
|
||||
try:
|
||||
open_items_by_key[key] = (project.name, reader.open_items(project.name))
|
||||
except ValidationError:
|
||||
broken_keys.add(key)
|
||||
|
||||
# Check 1 - stalled: a tracker project with zero open items whose kb/
|
||||
# page is `state: active` (#119 D27's anti-noise core: dormant,
|
||||
# completed and abandoned never reach here).
|
||||
for project in tracker_projects:
|
||||
key = normalize_project_name(project.name)
|
||||
if key in broken_keys:
|
||||
continue
|
||||
_, items = open_items_by_key[key]
|
||||
if items.count != 0:
|
||||
continue
|
||||
kb = kb_projects.get(key)
|
||||
if kb is not None and kb.state == "active":
|
||||
findings.append(Finding(
|
||||
CHECK_STALLED, kb.title,
|
||||
f"Tracker project '{project.name}' has zero open items and the kb/ page "
|
||||
"is state: active.",
|
||||
))
|
||||
checks_run.append(CHECK_STALLED)
|
||||
|
||||
# Check 2 - waiting-for overdue: any WAITING item whose follow_up_at
|
||||
# is older than the threshold (#119 D9/D30 - never the due date).
|
||||
for _key, (name, items) in open_items_by_key.items():
|
||||
for item in items.waiting:
|
||||
if item.follow_up_at is None:
|
||||
continue
|
||||
age_days = (today - item.follow_up_at).days
|
||||
if age_days > cfg.thresholds.stalled_waiting_days:
|
||||
findings.append(Finding(
|
||||
CHECK_WAITING_OVERDUE, name,
|
||||
f"'{item.title}' is {age_days} day(s) past its follow_up_at "
|
||||
f"({item.follow_up_at.isoformat()}).",
|
||||
))
|
||||
checks_run.append(CHECK_WAITING_OVERDUE)
|
||||
|
||||
# Check 3 - tracker project with no kb/ page, older than the
|
||||
# threshold (#119 D26's noise brake: an age threshold, not a marker).
|
||||
for project in tracker_projects:
|
||||
key = normalize_project_name(project.name)
|
||||
if key in kb_projects or project.created is None:
|
||||
continue
|
||||
age_weeks = _weeks_between(project.created, today)
|
||||
if age_weeks > cfg.thresholds.unpaged_project_weeks:
|
||||
findings.append(Finding(
|
||||
CHECK_UNPAGED_PROJECT, project.name,
|
||||
f"No kb/ page for this tracker project after {age_weeks:.1f} week(s) "
|
||||
f"(created {project.created.isoformat()}).",
|
||||
))
|
||||
checks_run.append(CHECK_UNPAGED_PROJECT)
|
||||
|
||||
# Check 4 - kb/ page with no open loop: `state: active` but either no
|
||||
# tracker project of this name exists, or it has zero open items. This
|
||||
# is the reverse direction of check 3's join (#119 D8's beidseitig
|
||||
# unmatched report - a rename on either side must surface somewhere).
|
||||
tracker_by_key = {normalize_project_name(p.name): p for p in tracker_projects}
|
||||
for key, kb in kb_projects.items():
|
||||
if kb.state != "active":
|
||||
continue
|
||||
project = tracker_by_key.get(key)
|
||||
if project is None:
|
||||
findings.append(Finding(
|
||||
CHECK_NO_OPEN_LOOP, kb.title,
|
||||
"state: active, but no tracker project of this name exists.",
|
||||
))
|
||||
continue
|
||||
if key in broken_keys:
|
||||
continue
|
||||
_, items = open_items_by_key[key]
|
||||
if items.count == 0:
|
||||
findings.append(Finding(
|
||||
CHECK_NO_OPEN_LOOP, kb.title,
|
||||
f"state: active, but tracker project '{project.name}' has zero open items.",
|
||||
))
|
||||
checks_run.append(CHECK_NO_OPEN_LOOP)
|
||||
|
||||
# Check 5 - someday/maybe items untouched for longer than the threshold.
|
||||
# Independent of the tracker project list, so it still runs when
|
||||
# `projects()` above failed but `someday_items()` does not.
|
||||
try:
|
||||
someday = reader.someday_items()
|
||||
except ValidationError as exc:
|
||||
checks_skipped.append((CHECK_SOMEDAY_STALE, str(exc)))
|
||||
else:
|
||||
for item in someday:
|
||||
if item.modified is None:
|
||||
continue
|
||||
age_months = _months_between(item.modified, today)
|
||||
if age_months > cfg.thresholds.someday_stale_months:
|
||||
findings.append(Finding(
|
||||
CHECK_SOMEDAY_STALE, item.title,
|
||||
f"Untouched for {age_months} month(s) (last modified "
|
||||
f"{item.modified.isoformat()}).",
|
||||
))
|
||||
checks_run.append(CHECK_SOMEDAY_STALE)
|
||||
|
||||
return ReviewReport(
|
||||
findings=tuple(findings),
|
||||
checks_run=tuple(checks_run),
|
||||
checks_skipped=tuple(checks_skipped),
|
||||
kb_project_count=len(kb_projects),
|
||||
)
|
||||
Reference in new issue
Block a user