mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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>
160 lines
5.2 KiB
Python
160 lines
5.2 KiB
Python
"""Drift sweep: which open notes already have a passing, trusted done_check."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass, field, replace
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
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
|
|
from todo_hooks.notes import Task, parse_detail, parse_list
|
|
from todo_hooks.trust import is_trusted
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from todo_hooks.checkrun import CheckResult
|
|
from todo_hooks.context import Context
|
|
|
|
|
|
USAGE = (
|
|
"usage: python3 -m todo_hooks sweep [--dry-run] [--report PATH] [--trust-file PATH]"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Tally:
|
|
"""Per-outcome counts plus the report rows they produced."""
|
|
|
|
total: int = 0
|
|
counts: dict[str, int] = field(default_factory=dict)
|
|
passing: list[str] = field(default_factory=list)
|
|
manual: list[str] = field(default_factory=list)
|
|
|
|
def record(self, outcome: str, label: str) -> None:
|
|
self.counts[outcome] = self.counts.get(outcome, 0) + 1
|
|
if outcome == "pass":
|
|
self.passing.append(label)
|
|
elif outcome == "manual":
|
|
self.manual.append(label)
|
|
|
|
def lines(self, *, dry_run: bool) -> list[str]:
|
|
head = "would-run " if dry_run else ""
|
|
rows = [head + row for row in self.passing]
|
|
rows += [f"# close by hand: {row}" for row in self.manual]
|
|
get = self.counts.get
|
|
tail = f"{get('untrusted', 0)} untrusted, {get('manual', 0)} manual"
|
|
tail += f", {get('nolocal', 0)} no local repo"
|
|
if dry_run:
|
|
summary = (
|
|
f"# dry-run: {self.total} open notes: "
|
|
f"{get('pass', 0)} would run, {tail}"
|
|
)
|
|
else:
|
|
summary = (
|
|
f"# swept {self.total} open notes: {get('pass', 0)} pass, "
|
|
f"{get('fail', 0)} fail, {get('timeout', 0)} timeout, {tail}"
|
|
)
|
|
return [*rows, summary]
|
|
|
|
|
|
def local_repos(src_root: Path) -> dict[str, Path]:
|
|
"""Map ``kuhyx/<origin name>`` to each git checkout directly under src_root."""
|
|
if not src_root.is_dir():
|
|
return {}
|
|
return {
|
|
expected_repo(child): child
|
|
for child in sorted(src_root.iterdir())
|
|
if (child / ".git").exists()
|
|
}
|
|
|
|
|
|
def _write_report(path: Path, lines: list[str]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".report-")
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write("\n".join(lines) + "\n")
|
|
Path(tmp).replace(path)
|
|
|
|
|
|
def run(
|
|
ctx: Context,
|
|
*,
|
|
dry_run: bool = False,
|
|
report: Path | None = None,
|
|
runner: Callable[..., CheckResult] = run_check,
|
|
) -> int:
|
|
"""Sweep every open repo note; 1 when the server cannot be reached."""
|
|
repos = local_repos(ctx.paths.src_root)
|
|
tally = Tally()
|
|
try:
|
|
client = ctx.make_client()
|
|
try:
|
|
rows = parse_list(client.call("list_tasks", {"status": "open"}))
|
|
tally.total = len(rows)
|
|
for row in rows:
|
|
root = repos.get(row.repo)
|
|
if root is None:
|
|
tally.record("nolocal", row.id8)
|
|
continue
|
|
try:
|
|
task = parse_detail(client.call("get_task", {"id": row.id8}))
|
|
except McpToolError:
|
|
continue
|
|
outcome = _outcome(ctx, task, root, runner, dry_run=dry_run)
|
|
label = f"{row.id8} {row.repo} {row.title}"
|
|
tally.record(outcome, label)
|
|
finally:
|
|
client.close()
|
|
except McpError as err:
|
|
ctx.say(f"sweep failed: {describe(err)}")
|
|
return 1
|
|
lines = tally.lines(dry_run=dry_run)
|
|
_write_report(report or ctx.paths.report_file, lines)
|
|
for line in lines:
|
|
ctx.out(line)
|
|
return 0
|
|
|
|
|
|
def _outcome(
|
|
ctx: Context,
|
|
task: Task,
|
|
root: Path,
|
|
runner: Callable[..., CheckResult],
|
|
*,
|
|
dry_run: bool,
|
|
) -> str:
|
|
check = task.done_check
|
|
if task.status == "done" or not check:
|
|
return "skip"
|
|
if task.is_manual:
|
|
return "manual"
|
|
if not is_trusted(ctx.paths.trust_file, check):
|
|
return "untrusted"
|
|
if dry_run:
|
|
return "pass"
|
|
result = runner(check, root, timeout=CHECK_TIMEOUT, wrapper=ctx.paths.capped)
|
|
return "timeout" if result.timed_out else "pass" if result.passed else "fail"
|
|
|
|
|
|
def main(ctx: Context, args: list[str]) -> int:
|
|
"""Parse ``--dry-run``, ``--report PATH``, ``--trust-file PATH`` and run."""
|
|
parser = argparse.ArgumentParser(prog="todo_hooks sweep", exit_on_error=False)
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--report", type=Path)
|
|
parser.add_argument("--trust-file", type=Path)
|
|
try:
|
|
opts = parser.parse_args(args)
|
|
except argparse.ArgumentError, SystemExit:
|
|
ctx.out(USAGE)
|
|
return 2
|
|
if opts.trust_file:
|
|
ctx.paths = replace(ctx.paths, trust_file=opts.trust_file)
|
|
return run(ctx, dry_run=opts.dry_run, report=opts.report)
|