utils/todo_hooks/closer.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

77 lines
2.7 KiB
Python

"""Post-commit closer: close ``Todo:`` trailer tasks whose check passes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from todo_hooks.checkrun import CHECK_TIMEOUT, run_check
from todo_hooks.client import McpError, McpToolError
from todo_hooks.context import describe
from todo_hooks.gitrepo import expected_repo, head_sha, head_todo_ids, toplevel
from todo_hooks.notes import Task, parse_detail
from todo_hooks.trust import is_trusted
if TYPE_CHECKING:
from pathlib import Path
from todo_hooks.client import TaskClient
from todo_hooks.context import Context
def _refusal(ctx: Context, root: Path, task: Task) -> str | None:
"""Why this task must not be auto-closed, or None when it may run."""
expected = expected_repo(root)
id8 = task.id[:8]
if task.status == "done":
return f"{id8} already done"
if task.repo != expected:
return f"{id8} belongs to {task.repo or 'no repo'}, not {expected}; left open"
if task.is_manual:
return f"{id8} close by hand (Check: manual)"
if not task.done_check:
return f"{id8} has no done_check; close by hand"
if not is_trusted(ctx.paths.trust_file, task.done_check):
return f"{id8} untrusted done_check; run: python3 -m todo_hooks trust {id8}"
return None
def _verdict(ctx: Context, root: Path, task_id: str, client: TaskClient) -> str:
"""One line saying what happened to the task; closes it on a trusted pass."""
try:
task = parse_detail(client.call("get_task", {"id": task_id}))
except McpToolError as err:
return f"{task_id[:8]} not found: {err}"
refusal = _refusal(ctx, root, task)
if refusal:
return refusal
result = run_check(task.done_check or "", root, timeout=CHECK_TIMEOUT)
if not result.passed:
return f"{task.id[:8]} left open: done_check {result.describe()}"
evidence = f"{head_sha(root)} done_check exit 0"
try:
client.call("close_task", {"id": task.id, "evidence": evidence})
except McpToolError as err:
return f"{task.id[:8]} close failed: {err}"
return f"closed {task.id[:8]}"
def run(ctx: Context) -> int:
"""Always 0: the commit already happened, so a failure here must stay quiet."""
root = toplevel(ctx.cwd)
ids = head_todo_ids(root) if root else []
if root is None or not ids:
return 0
try:
client = ctx.make_client()
except McpError as err:
ctx.say(f"warning: {describe(err)}; nothing closed")
return 0
try:
for task_id in ids:
ctx.say(_verdict(ctx, root, task_id, client))
except McpError as err:
ctx.say(f"warning: {describe(err)}; nothing closed")
finally:
client.close()
return 0