"""`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 build_reader from chemenu.tasks import config as tasks_config from chemenu.tasks.protocol import OpenItems, ReadSource, 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" # D3 (Gitea #139): a value a provider could not supply is reported as its own # finding, under checks 2 and 3 respectively, rather than silently skipped - # a real state both `superproductivity` (a task with neither `dueWithTime` # nor `dueDay`) and `caldav` (no server in the account returns # `DAV:creationdate`, so `created` falls back to the earliest item's own # `CREATED`, `None` for an empty project) can produce. Not separate entries # in `ALL_CHECKS`: each fires from within check 2's/check 3's own loop, so # the five-checks-run count is unaffected. CHECK_WAITING_NO_FOLLOWUP = "waiting_no_follow_up" CHECK_PROJECT_AGE_UNKNOWN = "project_age_unknown" # 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). `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) class ReviewReport: findings: tuple[Finding, ...] checks_run: tuple[str, ...] checks_skipped: tuple[tuple[str, str], ...] # (check, reason) kb_project_count: int source: Optional[ReadSource] @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 _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") try: source: Optional[ReadSource] = reader.source() except ValidationError: # The same read that would answer this has already failed, or is # about to below - `source` degrading to `None` here is no worse # than the check it would have described also being skipped. source = None 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). A # WAITING item with no follow_up_at at all is its own finding (D3), # not a silent skip - the provider genuinely has nothing to judge # staleness against. for _key, (name, items) in open_items_by_key.items(): for item in items.waiting: if item.follow_up_at is None: findings.append(Finding( CHECK_WAITING_NO_FOLLOWUP, name, f"'{item.title}' is WAITING but has no follow_up_at - cannot judge " "whether it is overdue.", item_id=item.id, )) 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()}).", item_id=item.id, )) 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). # A project with no determinable creation date is its own finding # (D3), not a silent skip - check 3 cannot judge its age either way. for project in tracker_projects: key = normalize_project_name(project.name) if key in kb_projects: continue if project.created is None: findings.append(Finding( CHECK_PROJECT_AGE_UNKNOWN, project.name, "No creation date available for this tracker project - cannot judge " "whether it needs a kb/ page yet.", )) 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()}).", item_id=item.id, )) 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), source=source, )