mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +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>
240 lines
8.4 KiB
Python
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}
|