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

240 lines
8.4 KiB
Python

"""Test doubles: a scriptable task client and a stdlib fake MCP HTTP server."""
from __future__ import annotations
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import threading
import time
from typing import TYPE_CHECKING, Any
from todo_hooks.client import McpToolError
if TYPE_CHECKING:
from collections.abc import Callable
TOKEN = "test-token"
SESSION = "sess-1"
def detail(
task_id: str,
*,
repo: str = "kuhyx/demo",
done_check: str | None = "true",
text: str = "# Title\n\nBody",
status: str = "todo",
by: str = "claude",
) -> str:
"""Build a get_task reply the way the Dart server formats it."""
lines = [f"id: {task_id}", "priority: medium", f"status: {status}"]
lines += ["created: 2026-11-02T10:00:00", "updated: 2026-11-02T10:00:00"]
lines.append(f"repo: {repo}")
if done_check is not None:
lines.append(f"done_check: {done_check}")
lines += [f"by: {by}", "---", text]
return "\n".join(lines)
def line(task_id: str, repo: str = "kuhyx/demo", title: str = "# Title") -> str:
"""One list_tasks line: id8, status, priority, repo, title."""
return "\t".join([task_id[:8], "todo", "medium", repo, title])
@dataclass
class FakeClient:
"""Answers tool calls from a dict of tool -> reply (str, callable, or error)."""
replies: dict[str, Any] = field(default_factory=dict)
calls: list[tuple[str, dict[str, str]]] = field(default_factory=list)
def call(self, tool: str, arguments: dict[str, str] | None = None) -> str:
"""Record the call and return or raise the scripted reply."""
args = arguments or {}
self.calls.append((tool, args))
reply = self.replies.get(tool, "")
if callable(reply):
reply = reply(args)
if isinstance(reply, Exception):
raise reply
return str(reply)
def close(self) -> None:
"""Match the real client; nothing to release."""
def tools(self) -> list[str]:
"""Names of the tools called, in order."""
return [name for name, _ in self.calls]
def task_client(*details: str, **extra: Any) -> FakeClient: # noqa: ANN401
"""A client whose get_task answers by id prefix from `details`."""
by_id = {d.splitlines()[0].removeprefix("id: "): d for d in details}
def get_task(args: dict[str, str]) -> str | Exception:
for task_id, text in by_id.items():
if task_id.startswith(args["id"]):
return text
return McpToolError("error: id: no live note")
return FakeClient({"get_task": get_task, "close_task": "closed x", **extra})
class FakeMcp:
"""A loopback server speaking just enough Streamable HTTP like mcp_dart."""
def __init__(self) -> None:
self.tools: dict[str, Callable[[dict[str, Any]], str]] = {}
self.requests: list[dict[str, Any]] = []
self.mode = "sse" # sse | json | garbage | rpc-error | tool-error
self.status = 200
self.delay = 0.0
self.stateless = False
self.token = TOKEN
owner = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args: object) -> None:
return
def _send(
self,
code: int,
body: str,
ctype: str,
extra: dict[str, str] | None = None,
) -> None:
data = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(data)
def do_DELETE(self) -> None:
owner.requests.append(
{"method": "DELETE", "session": self.headers.get("Mcp-Session-Id")}
)
self._send(200, "", "text/plain")
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
owner.requests.append(
{
"method": body.get("method"),
"body": body,
"session": self.headers.get("Mcp-Session-Id"),
"accept": self.headers.get("Accept"),
}
)
if self.headers.get("Authorization") != f"Bearer {owner.token}":
self._send(403, "Forbidden", "text/plain")
return
if owner.delay:
time.sleep(owner.delay)
if owner.status != 200:
self._send(owner.status, "boom", "text/plain")
return
self._answer(body)
def _answer(self, body: dict[str, Any]) -> None:
method = body.get("method")
if method == "notifications/initialized":
self._send(202, "", "text/plain")
elif method == "initialize":
result = {"result": {"protocolVersion": "2025-03-26"}}
self._reply(
body,
result,
{} if owner.stateless else {"Mcp-Session-Id": SESSION},
)
elif (
not owner.stateless
and self.headers.get("Mcp-Session-Id") != SESSION
):
err = {"error": {"code": -32000, "message": "no session"}}
self._reply(body, err)
else:
self._tool(body)
def _tool(self, body: dict[str, Any]) -> None:
params = body["params"]
if owner.mode == "rpc-error":
self._reply(body, {"error": {"code": -32601, "message": "nope"}})
elif owner.mode == "garbage":
self._send(200, "not json at all", "application/json")
elif owner.mode == "no-data":
self._send(200, "event: ping\n\n", "text/event-stream")
else:
text = owner.tools[params["name"]](params["arguments"])
result = {
"content": [{"type": "text", "text": text}],
"isError": owner.mode == "tool-error",
}
self._reply(body, {"result": result})
def _reply(
self,
body: dict[str, Any],
part: dict[str, Any],
extra: dict[str, str] | None = None,
) -> None:
payload = json.dumps({"jsonrpc": "2.0", "id": body.get("id"), **part})
if owner.mode == "json":
self._send(200, payload, "application/json", extra)
else:
sse = f"event: message\ndata: {payload}\n\n"
self._send(200, sse, "text/event-stream", extra)
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
@property
def url(self) -> str:
"""The /mcp endpoint on the ephemeral port."""
return f"http://127.0.0.1:{self._server.server_address[1]}/mcp"
def start(self) -> FakeMcp:
"""Begin serving in a daemon thread."""
self._thread.start()
return self
def stop(self) -> None:
"""Stop serving and release the port."""
self._server.shutdown()
self._server.server_close()
BODY = """Intro text.
## Todo:
- Add retry to sync | done_check: pytest -q tests/test_retry.py
- Document the flag
* Star bullet | done_check: true
## Other
- not a todo | done_check: false
"""
class FakeGh:
"""Scripted `gh api` answers by commit sha."""
def __init__(self, pulls: dict[str, list[dict[str, object]]]) -> None:
self.pulls = pulls
self.calls: list[list[str]] = []
def __call__(self, args: list[str]) -> str:
self.calls.append(args)
sha = args[1].split("/")[4]
return json.dumps(self.pulls.get(sha, []))
def make_pr(
number: int, body: str | None = BODY, merged: str | None = "2026-11-01"
) -> dict[str, object]:
return {"number": number, "body": body, "merged_at": merged}