mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 16:48:54 +01:00
todo_hooks: session-start, canonical token path
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f0070b353
commit
1fb04ce59d
@ -20,7 +20,7 @@ Run as `scripts/todo_hooks.sh <command>` or `PYTHONPATH=<utils> python3 -m todo_
|
||||
|
||||
## Server
|
||||
|
||||
`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp/client-token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns.
|
||||
`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp-client/token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns.
|
||||
|
||||
## Wiring a repo
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from todo_hooks import closer, import_pr, sweep, trust_cmd
|
||||
from todo_hooks import closer, import_pr, session_start, sweep, trust_cmd
|
||||
from todo_hooks.context import Context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -14,7 +14,9 @@ if TYPE_CHECKING:
|
||||
|
||||
from todo_hooks.client import TaskClient
|
||||
|
||||
USAGE = "usage: python3 -m todo_hooks {closer|sweep|trust|import-pr} [args]"
|
||||
USAGE = (
|
||||
"usage: python3 -m todo_hooks {closer|sweep|trust|import-pr|session-start} [args]"
|
||||
)
|
||||
|
||||
|
||||
def main(
|
||||
@ -27,13 +29,22 @@ def main(
|
||||
) -> int:
|
||||
"""Dispatch one subcommand; returns the process exit code."""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args or args[0] not in {"closer", "sweep", "trust", "import-pr"}:
|
||||
if not args or args[0] not in {
|
||||
"closer",
|
||||
"sweep",
|
||||
"trust",
|
||||
"import-pr",
|
||||
"session-start",
|
||||
}:
|
||||
print(USAGE)
|
||||
return 2
|
||||
ctx = Context.build(env, cwd, make_client)
|
||||
budget = session_start.BUDGET_SECONDS if args[0] == "session-start" else None
|
||||
ctx = Context.build(env, cwd, make_client, budget)
|
||||
command, rest = args[0], args[1:]
|
||||
if command == "closer":
|
||||
return closer.run(ctx)
|
||||
if command == "session-start":
|
||||
return session_start.run(ctx, sys.stdin.read() if stdin is None else stdin)
|
||||
if command == "sweep":
|
||||
return sweep.main(ctx, rest)
|
||||
if command == "trust":
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import http.client
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@ -84,24 +85,35 @@ def _parse_reply(content_type: str, body: str) -> dict[str, Any]:
|
||||
class McpClient:
|
||||
"""One lazily initialised MCP session; ``close`` it (or use ``with``)."""
|
||||
|
||||
def __init__(self, url: str, token: str, timeout: float = DEFAULT_TIMEOUT) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
token: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
budget: float | None = None,
|
||||
) -> None:
|
||||
if not url.startswith(("http://", "https://")):
|
||||
msg = f"unsupported MCP url: {url}"
|
||||
raise McpError(msg)
|
||||
self.url = url
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
self._deadline = None if budget is None else time.monotonic() + budget
|
||||
self._session: str | None = None
|
||||
self._ready = False
|
||||
self._next_id = 0
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls, env: Mapping[str, str], token_file: Path, timeout: float = DEFAULT_TIMEOUT
|
||||
cls,
|
||||
env: Mapping[str, str],
|
||||
token_file: Path,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
budget: float | None = None,
|
||||
) -> McpClient:
|
||||
"""Build from ``TODO_MCP_URL`` and the token (env or file)."""
|
||||
url = env.get("TODO_MCP_URL") or DEFAULT_URL
|
||||
return cls(url, load_token(env, token_file), timeout)
|
||||
return cls(url, load_token(env, token_file), timeout, budget)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
@ -119,12 +131,18 @@ class McpClient:
|
||||
}
|
||||
if self._session:
|
||||
headers["Mcp-Session-Id"] = self._session
|
||||
timeout = self._timeout
|
||||
if self._deadline is not None:
|
||||
timeout = min(timeout, self._deadline - time.monotonic())
|
||||
if timeout <= 0:
|
||||
msg = "time budget exhausted"
|
||||
raise McpUnavailable(msg)
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
self.url, data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self._timeout) as reply:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as reply:
|
||||
body = reply.read().decode("utf-8", "replace")
|
||||
return reply.headers.get("Content-Type", ""), body, reply.headers
|
||||
except urllib.error.HTTPError as err:
|
||||
|
||||
@ -34,13 +34,14 @@ class Context:
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: Path | None = None,
|
||||
make_client: Callable[[], TaskClient] | None = None,
|
||||
budget: float | None = None,
|
||||
) -> Context:
|
||||
"""Real defaults: ``os.environ``, the cwd, ``~`` paths, a live client."""
|
||||
use_env = dict(os.environ) if env is None else dict(env)
|
||||
paths = Paths.default(use_env)
|
||||
|
||||
def live() -> TaskClient:
|
||||
return McpClient.from_env(use_env, paths.token_file)
|
||||
return McpClient.from_env(use_env, paths.token_file, budget=budget)
|
||||
|
||||
return cls(
|
||||
env=use_env,
|
||||
|
||||
@ -28,7 +28,7 @@ class Paths:
|
||||
return cls(
|
||||
token_file=Path(
|
||||
env.get("TODO_MCP_TOKEN_FILE")
|
||||
or base / ".config" / "todo-mcp" / "client-token"
|
||||
or base / ".config" / "todo-mcp-client" / "token"
|
||||
),
|
||||
trust_file=Path(
|
||||
env.get("TODO_HOOKS_TRUST_FILE")
|
||||
|
||||
57
todo_hooks/session_start.py
Normal file
57
todo_hooks/session_start.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""SessionStart hook: print the current repo's open tasks, at most ten lines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from todo_hooks.gitrepo import expected_repo, origin_url, toplevel
|
||||
from todo_hooks.notes import parse_list
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from todo_hooks.context import Context
|
||||
|
||||
BUDGET_SECONDS = 0.8
|
||||
MAX_LINES = 10
|
||||
|
||||
|
||||
def _cwd_from(ctx: Context, stdin: str) -> Path:
|
||||
try:
|
||||
data = json.loads(stdin)
|
||||
except json.JSONDecodeError:
|
||||
return ctx.cwd
|
||||
cwd = data.get("cwd") if isinstance(data, dict) else None
|
||||
return Path(cwd) if cwd else ctx.cwd
|
||||
|
||||
|
||||
def summary(repo: str, listing: str) -> list[str]:
|
||||
"""Header plus task lines, truncated to MAX_LINES with a '+K more' tail."""
|
||||
rows = parse_list(listing)
|
||||
if not rows:
|
||||
return []
|
||||
out = [f"Open todo tasks for {repo} ({len(rows)}):"]
|
||||
shown = rows if len(rows) < MAX_LINES else rows[: MAX_LINES - 2]
|
||||
out += [f"- {row.id8} {row.title}" for row in shown]
|
||||
if len(shown) < len(rows):
|
||||
out.append(f'… +{len(rows) - len(shown)} more: list_tasks(repo="{repo}")')
|
||||
return out
|
||||
|
||||
|
||||
def run(ctx: Context, stdin: str) -> int:
|
||||
"""Always 0 and silent on any problem: a session must never wait on this."""
|
||||
try:
|
||||
root = toplevel(_cwd_from(ctx, stdin))
|
||||
if root is None or origin_url(root) is None:
|
||||
return 0
|
||||
repo = expected_repo(root)
|
||||
client = ctx.make_client()
|
||||
try:
|
||||
listing = client.call("list_tasks", {"repo": repo, "status": "open"})
|
||||
finally:
|
||||
client.close()
|
||||
for row in summary(repo, listing):
|
||||
ctx.out(row)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
return 0
|
||||
@ -196,3 +196,21 @@ def test_non_object_json_is_unparseable() -> None:
|
||||
def test_non_http_url_is_refused() -> None:
|
||||
with pytest.raises(McpError, match="unsupported"):
|
||||
McpClient("file:///etc/passwd", "t")
|
||||
|
||||
|
||||
def test_exhausted_budget_fails_before_any_request(mcp: FakeMcp) -> None:
|
||||
with pytest.raises(McpUnavailable, match="budget"):
|
||||
McpClient(mcp.url, TOKEN, budget=0).call("list_tasks")
|
||||
assert mcp.requests == []
|
||||
|
||||
|
||||
def test_budget_caps_each_request(mcp: FakeMcp) -> None:
|
||||
mcp.delay = 1.0
|
||||
with pytest.raises(McpUnavailable):
|
||||
McpClient(mcp.url, TOKEN, timeout=5, budget=0.3).call("list_tasks")
|
||||
|
||||
|
||||
def test_from_env_passes_budget(tmp_path: Path) -> None:
|
||||
file = tmp_path / "t"
|
||||
file.write_text("tok")
|
||||
assert McpClient.from_env({}, file, budget=0).call is not None
|
||||
|
||||
@ -102,7 +102,7 @@ def test_paths_default_and_env_overrides(tmp_path: Path) -> None:
|
||||
capped.parent.mkdir(parents=True)
|
||||
capped.write_text("#!/bin/sh\n")
|
||||
base = Paths.default({}, tmp_path)
|
||||
assert base.token_file == tmp_path / ".config/todo-mcp/client-token"
|
||||
assert base.token_file == tmp_path / ".config/todo-mcp-client/token"
|
||||
assert base.trust_file == tmp_path / ".config/todo-hooks/trusted-checks"
|
||||
assert base.report_file == tmp_path / ".local/state/todo-drift/report.txt"
|
||||
assert base.src_root == tmp_path / "src"
|
||||
|
||||
140
todo_hooks/tests/test_session_start.py
Normal file
140
todo_hooks/tests/test_session_start.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""Tests for the SessionStart task summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from todo_hooks import __main__ as cli
|
||||
from todo_hooks.tests._fakes import TOKEN, FakeMcp, line
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ORIGIN = "git@github.com:kuhyx/demo.git"
|
||||
|
||||
|
||||
def _rows(count: int) -> str:
|
||||
return "\n".join(
|
||||
line(f"{i:08x}-x", "kuhyx/demo", f"# Task {i}") for i in range(count)
|
||||
)
|
||||
|
||||
|
||||
def _env(mcp: FakeMcp) -> dict[str, str]:
|
||||
return {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN": TOKEN}
|
||||
|
||||
|
||||
def _stdin(cwd: Path) -> str:
|
||||
return json.dumps({"cwd": str(cwd), "hook_event_name": "SessionStart"})
|
||||
|
||||
|
||||
def test_prints_header_and_tasks(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
seen: list[dict[str, object]] = []
|
||||
mcp.tools["list_tasks"] = lambda a: seen.append(a) or _rows(2)
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||
assert capsys.readouterr().out.splitlines() == [
|
||||
"Open todo tasks for kuhyx/demo (2):",
|
||||
"- 00000000 Task 0",
|
||||
"- 00000001 Task 1",
|
||||
]
|
||||
assert seen == [{"repo": "kuhyx/demo", "status": "open"}]
|
||||
|
||||
|
||||
def test_nine_tasks_fit_ten_lines(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.tools["list_tasks"] = lambda _a: _rows(9)
|
||||
cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root))
|
||||
assert len(capsys.readouterr().out.splitlines()) == 10
|
||||
|
||||
|
||||
def test_more_than_nine_is_truncated(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.tools["list_tasks"] = lambda _a: _rows(15)
|
||||
cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root))
|
||||
lines = capsys.readouterr().out.splitlines()
|
||||
assert len(lines) == 10
|
||||
assert lines[0] == "Open todo tasks for kuhyx/demo (15):"
|
||||
assert lines[-1] == '… +7 more: list_tasks(repo="kuhyx/demo")'
|
||||
|
||||
|
||||
def test_no_tasks_prints_nothing(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.tools["list_tasks"] = lambda _a: "no tasks match"
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_cwd_fallback_when_stdin_is_not_json(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="not json")
|
||||
assert "kuhyx/demo" in capsys.readouterr().out
|
||||
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="[1]")
|
||||
assert "kuhyx/demo" in capsys.readouterr().out
|
||||
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="{}")
|
||||
assert "kuhyx/demo" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_silent_without_git_or_origin(
|
||||
mcp: FakeMcp,
|
||||
make_repo: Callable[..., Path],
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(plain)) == 0
|
||||
local = make_repo("local")
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(local)) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
assert mcp.requests == []
|
||||
|
||||
|
||||
def test_silent_when_server_down_or_token_bad(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
bad = {**_env(mcp), "TODO_MCP_TOKEN": "wrong"}
|
||||
assert cli.main(["session-start"], env=bad, stdin=_stdin(root)) == 0
|
||||
dead = {**_env(mcp), "TODO_MCP_URL": "http://127.0.0.1:1/mcp"}
|
||||
assert cli.main(["session-start"], env=dead, stdin=_stdin(root)) == 0
|
||||
no_token = {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN_FILE": "/nonexistent"}
|
||||
assert cli.main(["session-start"], env=no_token, stdin=_stdin(root)) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_silent_on_unexpected_error(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.mode = "garbage"
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_slow_server_stays_within_budget(
|
||||
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = make_repo("demo", origin=ORIGIN)
|
||||
mcp.delay = 3.0
|
||||
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||
start = time.monotonic()
|
||||
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||
assert time.monotonic() - start < 1.6
|
||||
assert capsys.readouterr().out == ""
|
||||
Loading…
Reference in New Issue
Block a user