utils/todo_hooks/notes.py
Krzysztof kuhy Rudnicki aec96eefd3 Add todo_hooks closer, drift sweep, PR import
Python 3.14 stdlib package wiring git to the todo MCP server:
- closer (post-commit): runs the done_check of each Todo: trailer
  task and close_task's it only on exit 0. It FAILS OPEN: the commit
  already happened, so a down server, bad token or no gh prints one
  warning and exits 0 instead of failing the hook.
- sweep: nightly drift report (systemd units shipped, not enabled).
- import-pr: a merged PR's '## Todo:' items become tasks, deduped.
- A done_check runs only if its sha256 is in
  ~/.config/todo-hooks/trusted-checks; 'Check: manual' notes are
  never auto-closed.
- Hooks exposed in .pre-commit-hooks.yaml (post-commit, post-merge,
  post-rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:24:29 +01:00

84 lines
2.2 KiB
Python

"""Parse the todo server's list_tasks and get_task text replies."""
from __future__ import annotations
from dataclasses import dataclass
import re
_FIELDS = ("id", "priority", "status", "created", "updated", "repo")
_FIELDS += ("done_check", "by", "evidence")
_META_LINE = re.compile(rf"^({'|'.join(_FIELDS)}): ?(.*)$")
_MANUAL = re.compile(r"^\s*check:\s*manual\s*$", re.IGNORECASE | re.MULTILINE)
_COLUMNS = 5
def clean_title(title: str) -> str:
"""Drop surrounding space and a leading markdown ``#``."""
return re.sub(r"^#+\s*", "", title.strip())
@dataclass(frozen=True)
class TaskLine:
"""One list_tasks row."""
id8: str
status: str
priority: str
repo: str
title: str
@dataclass(frozen=True)
class Task:
"""One get_task reply: metadata lines, ``---``, then the note text."""
id: str
status: str
repo: str | None
done_check: str | None
text: str
@property
def title(self) -> str:
return clean_title(self.text.split("\n", 1)[0])
@property
def is_manual(self) -> bool:
return _MANUAL.search(self.text) is not None
def parse_list(text: str) -> list[TaskLine]:
"""Rows of tab-separated ``id8 status priority repo title`` lines."""
rows = []
for raw in text.splitlines():
parts = raw.split("\t")
if len(parts) >= _COLUMNS:
title = clean_title("\t".join(parts[4:]))
rows.append(TaskLine(parts[0], parts[1], parts[2], parts[3], title))
return rows
def parse_detail(text: str) -> Task:
"""Split a get_task reply into metadata fields and the note text."""
meta: dict[str, str] = {}
last = ""
lines = text.split("\n")
body: list[str] = []
for index, row in enumerate(lines):
if row == "---":
body = lines[index + 1 :]
break
found = _META_LINE.match(row)
if found:
last = found.group(1)
meta[last] = found.group(2)
elif last:
meta[last] += "\n" + row
return Task(
id=meta.get("id", ""),
status=meta.get("status", ""),
repo=meta.get("repo"),
done_check=meta.get("done_check"),
text="\n".join(body),
)