diff --git a/feedback_inbox/.pre-commit-config.yaml b/feedback_inbox/.pre-commit-config.yaml new file mode 100644 index 0000000..c909bf4 --- /dev/null +++ b/feedback_inbox/.pre-commit-config.yaml @@ -0,0 +1,143 @@ +# Pre-commit Configuration for feedback_inbox +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files +# Update: pre-commit autoupdate + +# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is +# one level up. pre-commit always runs hooks from the git root, so every path +# below is written relative to THAT, not to this directory, and the top-level +# `files` key keeps these hooks off the sibling subprojects. CI invokes this +# config exactly the same way: +# pre-commit run --config feedback_inbox/.pre-commit-config.yaml --all-files +files: ^feedback_inbox/ + +default_language_version: + python: python3 +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + args: [--maxkb=2000] + - id: check-merge-conflict + - id: detect-private-key + - id: debug-statements + - id: name-tests-test + args: [--pytest-test-first] + - id: check-ast + - id: mixed-line-ending + args: [--fix=lf] + - id: requirements-txt-fixer + + - repo: local + hooks: + - id: file-length-cap + name: file length <= 250 lines + entry: bash scripts/check_file_length.sh + language: system + pass_filenames: true + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh feedback_inbox + language: script + # always_run so the top-level `files` filter cannot skip the one hook + # whose job is to notice that filter has stopped matching anything. + always_run: true + pass_filenames: false + - id: no-noqa + name: Block noqa comments + entry: '(?i)#\s*(noqa|type:\s*ignore)' + language: pygrep + types: [python] + - id: no-ruff-noqa + name: Block ruff noqa file-level comments + entry: '(?i)#\s*ruff:\s*noqa' + language: pygrep + types: [python] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.4 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + args: + - --ignore-missing-imports + - --no-error-summary + - --disable-error-code=no-untyped-def + - --disable-error-code=no-untyped-call + - --disable-error-code=var-annotated + - --disable-error-code=no-any-unimported + - --disable-error-code=type-arg + - --disable-error-code=no-any-return + - --disable-error-code=misc + - --disable-error-code=unused-ignore + - --disable-error-code=unreachable + - --disable-error-code=assignment + - --disable-error-code=no-redef + - --disable-error-code=attr-defined + - --disable-error-code=arg-type + - --disable-error-code=union-attr + - --disable-error-code=call-overload + - --disable-error-code=return-value + - --disable-error-code=redundant-cast + - --disable-error-code=empty-body + - --disable-error-code=list-item + + - repo: https://github.com/pylint-dev/pylint + rev: v4.0.8 + hooks: + - id: pylint + args: + - --rcfile=feedback_inbox/pyproject.toml + - --fail-under=10 + - --jobs=4 + additional_dependencies: + - pytest + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + args: + - -c + - feedback_inbox/pyproject.toml + - --severity-level=high + - --confidence-level=medium + - --skip=B113 + additional_dependencies: ["bandit[toml]"] + exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$) + + - repo: local + hooks: + - id: pytest-coverage + name: pytest with coverage enforcement + entry: scripts/run_subproject_tests.sh feedback_inbox + language: script + types: [python] + pass_filenames: false + require_serial: true + stages: [pre-push] + + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + - --skip=*.json,*.lock,.git,__pycache__,.venv diff --git a/feedback_inbox/README.md b/feedback_inbox/README.md new file mode 100644 index 0000000..6792153 --- /dev/null +++ b/feedback_inbox/README.md @@ -0,0 +1,64 @@ +# feedback_inbox + +Turns every game's dev-feedback bundle into one todo-app task, so feedback +kuhy saves with F12 is never forgotten. + +A game saves `~/data/_binaries/feedback//` (`report.json`, +screenshots, logs) and appends a ready-to-run Claude prompt to +`feedback-.md` beside it. This package imports each complete bundle once +as a task in the todo app for `kuhyx/`. + +## Task shape + +- Title: `MANUAL WORK REQUIRED: feedback: ` (note capped + at 120 characters). The prefix makes todoImplementer list it as a human part and never + run it. No note: `... feedback: `. A `report.json` that does + not parse still gets a task, tagged `[corrupt report.json]`; the note then + comes from the markdown entry. +- Body: a `Due: ` line, then the bundle's markdown entry (the + prompt with absolute paths). +- The task is its own note (`add_task` with `new_note`), so the title is exact. +- Closing the task cancels the feedback. "Act on feedback" in a Claude session + means working every open ` feedback:` task. + +The todo app has no due-date or reminder field yet, so the due time is only +text in the body. + +## How it runs + +| Unit | Does | +|---|---| +| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` | +| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` | +| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) | + +`.path` units take no globs: `feedback-inbox sync` writes the folder list into +the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only +when the set of games changed. + +A bundle is complete when `report.json` is at least 2 s old and its markdown +entry exists (matched by the stamp folder in the entry's paths). Incomplete +bundles are retried; `run` waits up to 15 s for them. + +The import marker is `.inbox-imported.json` inside the bundle (task id, title, +due). It is written only after the todo server accepted the task, so a server +outage leaves no marker, exits non-zero, and the next sweep retries. + +## Install + +```bash +scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root +feedback_inbox/install.sh +``` + +`install.sh` builds a stdlib-only venv, links `feedback-inbox` into +`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token` +(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables +the units. Enabling sweeps once, so existing bundles are imported then. + +## Tests + +`pytest` from this directory (100 % branch coverage enforced). Tests use a temp +`HOME`, a fake todo client, and a local HTTP server for the MCP client; the +autouse `conftest.py` redirects the one path resolver and points the todo URL +at a dead port. diff --git a/feedback_inbox/feedback_inbox/__init__.py b/feedback_inbox/feedback_inbox/__init__.py new file mode 100644 index 0000000..91056b9 --- /dev/null +++ b/feedback_inbox/feedback_inbox/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Turns every game dev-feedback bundle into one todo-app task.""" diff --git a/feedback_inbox/feedback_inbox/__main__.py b/feedback_inbox/feedback_inbox/__main__.py new file mode 100644 index 0000000..672dd50 --- /dev/null +++ b/feedback_inbox/feedback_inbox/__main__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""``python -m feedback_inbox``.""" + +from __future__ import annotations + +from feedback_inbox._cli import main + + +def run() -> int: + """Run the CLI on ``sys.argv``.""" + return main() + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/feedback_inbox/feedback_inbox/_bundle.py b/feedback_inbox/feedback_inbox/_bundle.py new file mode 100644 index 0000000..8d000b3 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_bundle.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Finding games' feedback folders and bundles, and the stamp arithmetic.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +BINARIES_SUFFIX = "_binaries" +STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S" +_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$") +_REMINDER_HOUR = 10 + + +def binaries_dirs(data_root: Path) -> list[Path]: + """Every ``_binaries`` directory directly under ``data_root``.""" + if not data_root.is_dir(): + return [] + return sorted( + p + for p in data_root.iterdir() + if p.name.endswith(BINARIES_SUFFIX) and p.is_dir() + ) + + +def feedback_dirs(data_root: Path) -> list[Path]: + """Every existing ``_binaries/feedback`` directory.""" + return [ + d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir() + ] + + +def repo_name(feedback_dir: Path) -> str: + """The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``.""" + return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX) + + +def bundles_in(feedback_dir: Path) -> list[Path]: + """Bundle folders (named by a millisecond stamp), oldest first.""" + return sorted( + p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name) + ) + + +def parse_stamp(stamp: str) -> datetime: + """The local time a bundle was saved, from its folder name.""" + return datetime.strptime(stamp[:19], STAMP_FORMAT) + + +def due_at(stamp: str) -> datetime: + """The reminder time: the day after the bundle, at 10:00 local.""" + day = parse_stamp(stamp) + timedelta(days=1) + return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0) diff --git a/feedback_inbox/feedback_inbox/_cli.py b/feedback_inbox/feedback_inbox/_cli.py new file mode 100644 index 0000000..1da10a6 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_cli.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""``feedback-inbox run | sync``: what the systemd units execute.""" + +from __future__ import annotations + +import argparse +import fcntl +import logging +import time +from typing import TYPE_CHECKING + +from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled +from feedback_inbox._paths import resolve_paths +from feedback_inbox._todo import McpTodoClient +from feedback_inbox._units import run_systemctl, sync_units + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from feedback_inbox._todo import TodoClient + +LOG = logging.getLogger("feedback_inbox") +OWNER = "kuhyx" +DEFAULT_WAIT = 15.0 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="feedback-inbox") + sub = parser.add_subparsers(dest="command", required=True) + run = sub.add_parser("run", help="import new bundles as todo tasks") + run.add_argument("--wait", type=float, default=DEFAULT_WAIT) + sub.add_parser("sync", help="re-arm the path units for the current games") + return parser + + +def _run(wait: float, client: TodoClient | None) -> int: + paths = resolve_paths() + todo = client or McpTodoClient(paths.mcp_url, paths.token_file) + paths.lock_file.parent.mkdir(parents=True, exist_ok=True) + # Blocking lock: a second run waits, then finds the markers and skips. + with paths.lock_file.open("a", encoding="utf-8") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + results = run_settled( + paths.data_root, + Importer(todo, OWNER), + Waiter(wait, time.time, time.sleep), + ) + failed = 0 + for result in results: + if result.outcome is Outcome.FAILED: + failed += 1 + LOG.error("failed %s: %s", result.bundle, result.detail) + elif result.outcome is not Outcome.SKIPPED: + LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "") + return 1 if failed else 0 + + +def _sync(systemctl: Callable[[Sequence[str]], int]) -> int: + paths = resolve_paths() + try: + changed = sync_units(paths.data_root, paths.unit_dir, systemctl) + except RuntimeError: + LOG.exception("sync failed") + return 1 + LOG.info("watch list %s", "rewritten" if changed else "unchanged") + return 0 + + +def main( + argv: Sequence[str] | None = None, + *, + client: TodoClient | None = None, + systemctl: Callable[[Sequence[str]], int] = run_systemctl, +) -> int: + """Entry point; ``client`` and ``systemctl`` are test seams.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = _parser().parse_args(argv) + if args.command == "run": + return _run(args.wait, client) + return _sync(systemctl) diff --git a/feedback_inbox/feedback_inbox/_entry.py b/feedback_inbox/feedback_inbox/_entry.py new file mode 100644 index 0000000..31caf44 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_entry.py @@ -0,0 +1,74 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""A bundle's markdown entry and its report.json.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +_SECTION = re.compile(r"(?m)^(?=## )") +_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`") +_BODY_END = re.compile(r"(?m)^- Screenshot") + + +@dataclass(frozen=True) +class Report: + """The fields of report.json the inbox needs.""" + + note: str + game: str + combo: str + + +def find_entry(feedback_dir: Path, bundle: Path) -> str | None: + """The ``feedback-.md`` section whose paths point into ``bundle``. + + Matched by the stamp folder in its paths, not by the heading time (which + drops the milliseconds), so a bundle never picks up a neighbour's entry. + """ + needle = f"/{bundle.name}/" + for md in sorted(feedback_dir.glob("feedback-*.md")): + try: + text = md.read_text(encoding="utf-8") + except OSError, UnicodeDecodeError: + continue + for section in _SECTION.split(text): + if needle in section: + return section.strip() + return None + + +def read_report(path: Path) -> Report | None: + """Parse report.json; ``None`` when it cannot be read as a JSON object.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except OSError, UnicodeDecodeError, json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + return Report( + note=_text(data.get("note")), + game=_text(data.get("game")), + combo=_text(data.get("combo")), + ) + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +def note_from_entry(entry: str) -> str: + """His note, as written between the heading and the file list.""" + _head, _, rest = entry.partition("\n") + return _BODY_END.split(rest, maxsplit=1)[0].strip() + + +def game_and_combo(entry: str) -> tuple[str, str]: + """Game and combo from the entry's ``Game ..., combo ...`` line.""" + found = _GAME.search(entry) + return (found.group(1), found.group(2)) if found else ("", "") diff --git a/feedback_inbox/feedback_inbox/_inbox.py b/feedback_inbox/feedback_inbox/_inbox.py new file mode 100644 index 0000000..24ce224 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_inbox.py @@ -0,0 +1,176 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Turn complete feedback bundles into todo tasks, once each.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import json +import logging +from typing import TYPE_CHECKING + +from feedback_inbox._bundle import ( + bundles_in, + due_at, + feedback_dirs, + parse_stamp, + repo_name, +) +from feedback_inbox._entry import ( + Report, + find_entry, + game_and_combo, + note_from_entry, + read_report, +) +from feedback_inbox._todo import TodoError + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from feedback_inbox._todo import TodoClient + +LOG = logging.getLogger("feedback_inbox") +MARKER = ".inbox-imported.json" +SETTLE_SECONDS = 2.0 +TITLE_CAP = 120 +MANUAL_PREFIX = "MANUAL WORK REQUIRED: " +CORRUPT_TAG = " [corrupt report.json]" +CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry." + + +class Outcome(StrEnum): + """What happened to one bundle in a run.""" + + IMPORTED = "imported" + SKIPPED = "skipped" + PENDING = "pending" + FAILED = "failed" + + +@dataclass(frozen=True) +class Result: + """One bundle's outcome, for the journal and the exit code.""" + + bundle: Path + outcome: Outcome + task_id: str | None = None + detail: str = "" + + +@dataclass(frozen=True) +class TaskDraft: + """The task to create: title, body and due time.""" + + title: str + body: str + due: str + + +def _first_line(note: str, fallback: str) -> str: + first = next((x.strip() for x in note.splitlines() if x.strip()), "") + if len(first) > TITLE_CAP: + first = first[: TITLE_CAP - 1] + "…" + return first or fallback + + +def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft: + """Compose the title and body from the entry and (maybe corrupt) report.""" + found_game, found_combo = game_and_combo(entry) + note = note_from_entry(entry) if report is None else report.note + game = (report.game if report else "") or found_game + combo = (report.combo if report else "") or found_combo + clock = parse_stamp(stamp).strftime("%H:%M") + fallback = " ".join(x for x in (game, combo, clock) if x) + suffix = CORRUPT_TAG if report is None else "" + title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}" + due = due_at(stamp) + lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""] + if report is None: + lines += [CORRUPT_LINE, ""] + return TaskDraft(title, "\n".join([*lines, entry]), due.isoformat()) + + +def _write_marker( + bundle: Path, draft: TaskDraft, task_id: str, *, merged: bool +) -> None: + payload = { + "task_id": task_id, + "title": draft.title, + "due": draft.due, + "merged": merged, + } + tmp = bundle / f"{MARKER}.tmp" + tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + tmp.replace(bundle / MARKER) + + +@dataclass(frozen=True) +class Importer: + """Who files the tasks (``client``) under which GitHub owner.""" + + client: TodoClient + owner: str + settle: float = SETTLE_SECONDS + + def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result: + """Import one bundle if it is complete and not yet imported.""" + if (bundle / MARKER).exists(): + return Result(bundle, Outcome.SKIPPED) + report_path = bundle / "report.json" + entry = find_entry(feedback_dir, bundle) + if entry is None or not report_path.is_file(): + return Result(bundle, Outcome.PENDING, detail="report or entry missing") + if now - report_path.stat().st_mtime < self.settle: + return Result(bundle, Outcome.PENDING, detail="report.json settling") + repo = repo_name(feedback_dir) + draft = build_task(repo, bundle.name, entry, read_report(report_path)) + try: + ref = self.client.add_task(f"{self.owner}/{repo}", draft.title, draft.body) + except TodoError as exc: + return Result(bundle, Outcome.FAILED, detail=str(exc)) + detail = "merged into an existing note" if ref.merged else "" + try: + _write_marker(bundle, draft, ref.task_id, merged=ref.merged) + except OSError as exc: + return Result( + bundle, + Outcome.FAILED, + ref.task_id, + f"task {ref.task_id} filed but marker not written: {exc}", + ) + return Result(bundle, Outcome.IMPORTED, ref.task_id, detail) + + +@dataclass(frozen=True) +class Waiter: + """How long to wait for half-written bundles, and the clock to do it by.""" + + wait: float + clock: Callable[[], float] + sleep: Callable[[float], None] + + +def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]: + """Import every complete, not yet imported bundle of every game.""" + return [ + importer.import_bundle(folder, bundle, now) + for folder in feedback_dirs(data_root) + for bundle in bundles_in(folder) + ] + + +def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]: + """Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending. + + A bundle is written in pieces; the watch fires on the first piece, so a + short wait lets the rest land instead of leaving it to the timer. + """ + deadline = waiter.clock() + waiter.wait + while True: + results = run_once(data_root, importer, waiter.clock()) + pending = any(r.outcome is Outcome.PENDING for r in results) + if not pending or waiter.clock() >= deadline: + return results + waiter.sleep(1.0) diff --git a/feedback_inbox/feedback_inbox/_paths.py b/feedback_inbox/feedback_inbox/_paths.py new file mode 100644 index 0000000..b92c78f --- /dev/null +++ b/feedback_inbox/feedback_inbox/_paths.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Every on-disk and network location, resolved in one place. + +Tests redirect :func:`resolve_paths` once; nothing else names a real path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +MCP_URL = "http://127.0.0.1:8735/mcp" +# Port 9 (discard) refuses connections: a test that forgets to inject a client +# fails closed instead of reaching the live todo server. +DEAD_URL = "http://127.0.0.1:9/mcp" + + +@dataclass(frozen=True) +class Paths: + """Where the inbox reads games, its token, its lock and its units.""" + + data_root: Path + token_file: Path + lock_file: Path + unit_dir: Path + mcp_url: str + + @classmethod + def under(cls, root: Path) -> Paths: + """A throwaway layout below ``root`` that can never reach the live app.""" + return cls( + data_root=root / "data", + token_file=root / "token", + lock_file=root / "state" / "lock", + unit_dir=root / "units", + mcp_url=DEAD_URL, + ) + + +def resolve_paths(given: Paths | None = None) -> Paths: + """Return ``given`` or the real layout under the user's home.""" + if given is not None: + return given + home = Path.home() + return Paths( + data_root=home / "data", + token_file=home / ".config" / "feedback-inbox" / "token", + lock_file=home / ".local" / "share" / "feedback-inbox" / "lock", + unit_dir=home / ".config" / "systemd" / "user", + mcp_url=MCP_URL, + ) diff --git a/feedback_inbox/feedback_inbox/_todo.py b/feedback_inbox/feedback_inbox/_todo.py new file mode 100644 index 0000000..ff66413 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_todo.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The todo app's MCP server as a minimal ``add_task`` client.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +from typing import TYPE_CHECKING, Protocol +import urllib.error +import urllib.request + +if TYPE_CHECKING: + from pathlib import Path + +_PROTOCOL = "2025-03-26" +_TIMEOUT = 20 +_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)") + + +class TodoError(Exception): + """The todo server could not take the task.""" + + +@dataclass(frozen=True) +class TaskRef: + """The task a call created (or the note it was merged into).""" + + task_id: str + merged: bool + + +class TodoClient(Protocol): + """What the inbox needs from a todo app.""" + + def add_task(self, repo: str, title: str, body: str) -> TaskRef: + """Create one task in its own note and return its id.""" + raise NotImplementedError + + +class McpTodoClient: + """Streamable-HTTP MCP client: initialize, then one ``add_task`` call.""" + + def __init__(self, url: str, token_file: Path) -> None: + """Remember where the server and the (lazily read) token are.""" + self._url = url + self._token_file = token_file + + def add_task(self, repo: str, title: str, body: str) -> TaskRef: + """Create a task with its own note (exact title) via ``add_task``.""" + token = self._token() + session = self._initialize(token) + arguments = {"repo": repo, "title": title, "body": body, "new_note": True} + reply = self._post( + token, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "add_task", "arguments": arguments}, + }, + session, + )[1] + return _task_ref(reply) + + def _token(self) -> str: + try: + return self._token_file.read_text(encoding="utf-8").strip() + except OSError as exc: + msg = f"token file unreadable: {self._token_file}" + raise TodoError(msg) from exc + + def _initialize(self, token: str) -> str: + init = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": _PROTOCOL, + "capabilities": {}, + "clientInfo": {"name": "feedback-inbox", "version": "0.1.0"}, + }, + } + headers, _body = self._post(token, init, None) + session = headers.get("mcp-session-id") + if not session: + msg = "todo server sent no session id" + raise TodoError(msg) + self._post( + token, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + session, + ) + return session + + def _post( + self, + token: str, + payload: dict[str, object], + session: str | None, + ) -> tuple[dict[str, str], str]: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if session: + headers["mcp-session-id"] = session + request = urllib.request.Request( + self._url, + data=json.dumps(payload).encode(), + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp: + return ( + {k.lower(): v for k, v in resp.headers.items()}, + resp.read().decode(), + ) + except urllib.error.HTTPError as exc: + exc.close() + msg = f"todo server answered HTTP {exc.code}" + raise TodoError(msg) from exc + except OSError as exc: + msg = f"todo server unreachable: {exc}" + raise TodoError(msg) from exc + + +def _task_ref(body: str) -> TaskRef: + text = body.strip() + if not text.startswith("{"): + lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")] + text = next((x for x in lines if x.startswith("{")), "") + try: + result = json.loads(text)["result"] + said = str(result["content"][0]["text"]) + failed = bool(result.get("isError")) + except (ValueError, KeyError, IndexError, TypeError) as exc: + msg = f"unparsable MCP reply: {body[:80]!r}" + raise TodoError(msg) from exc + found = _REPLY.match(said) + if failed or not found: + msg = f"add_task refused: {said[:120]}" + raise TodoError(msg) + return TaskRef(task_id=found.group(2), merged=found.group(1) != "added") diff --git a/feedback_inbox/feedback_inbox/_units.py b/feedback_inbox/feedback_inbox/_units.py new file mode 100644 index 0000000..bd6c065 --- /dev/null +++ b/feedback_inbox/feedback_inbox/_units.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Keep the path units' watch lists equal to the games that exist. + +systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed +in a generated drop-in. The set changes only when a game is added or removed; +the drop-in is rewritten, and systemd reloaded, only then (a reload per tick +would be the daemon-reload loop the fleet rules forbid). +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +from feedback_inbox._bundle import binaries_dirs, feedback_dirs + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + +WATCH_PATH = "feedback-inbox.path" +SYNC_PATH = "feedback-inbox-sync.path" +SYSTEMCTL = "/usr/bin/systemctl" +_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n" + + +def _dropin(unit_dir: Path, unit: str) -> Path: + return unit_dir / f"{unit}.d" / "dirs.conf" + + +def _render(folders: list[Path]) -> str: + return _HEADER + "".join(f"PathChanged={d}\n" for d in folders) + + +def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]: + """The drop-ins that should exist: feedback folders, and game folders.""" + return { + _dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)), + _dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)), + } + + +def run_systemctl(args: Sequence[str]) -> int: + """Run ``systemctl --user `` and return its exit code.""" + return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode + + +def sync_units( + data_root: Path, + unit_dir: Path, + systemctl: Callable[[Sequence[str]], int], +) -> bool: + """Rewrite drifted drop-ins, then reload and re-arm once; True if changed.""" + changed = False + for path, text in desired(data_root, unit_dir).items(): + if path.is_file() and path.read_text(encoding="utf-8") == text: + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + changed = True + if not changed: + return False + for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]): + if systemctl(args): + msg = f"systemctl --user {' '.join(args)} failed" + raise RuntimeError(msg) + return True diff --git a/feedback_inbox/feedback_inbox/tests/__init__.py b/feedback_inbox/feedback_inbox/tests/__init__.py new file mode 100644 index 0000000..658c557 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Tests for feedback_inbox.""" diff --git a/feedback_inbox/feedback_inbox/tests/conftest.py b/feedback_inbox/feedback_inbox/tests/conftest.py new file mode 100644 index 0000000..dd38fc6 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/conftest.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Redirect the one path resolver, so no test can touch real user state. + +Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``; +redirecting that one function moves all of them (kuhy's real ``~/data``, +token file, lock and systemd unit dir included). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from feedback_inbox._paths import Paths + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(autouse=True) +def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths: + """Point every default path at ``tmp_path`` before any test runs.""" + redirected = Paths.under(tmp_path / "home") + + def _resolve(given: Paths | None = None) -> Paths: + return given if given is not None else redirected + + monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve) + redirected.data_root.mkdir(parents=True) + return redirected diff --git a/feedback_inbox/feedback_inbox/tests/test_bundle.py b/feedback_inbox/feedback_inbox/tests/test_bundle.py new file mode 100644 index 0000000..99c05c8 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_bundle.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Discovery, stamps and due dates.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name +from feedback_inbox.tests.test_support import make_bundle + +if TYPE_CHECKING: + from feedback_inbox._paths import Paths + + +def test_feedback_dirs_covers_every_game(paths: Paths) -> None: + make_bundle(paths.data_root, game="cleanup-plate") + make_bundle(paths.data_root, game="dread-grid") + (paths.data_root / "other" / "feedback").mkdir(parents=True) + (paths.data_root / "empty_binaries").mkdir() + (paths.data_root / "file_binaries").write_text("x", encoding="utf-8") + found = feedback_dirs(paths.data_root) + assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"] + + +def test_feedback_dirs_without_data_root(paths: Paths) -> None: + assert feedback_dirs(paths.data_root / "missing") == [] + + +def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None: + first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124") + make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162") + feedback = first.parent + (feedback / "notes").mkdir() + (feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8") + names = [b.name for b in bundles_in(feedback)] + assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"] + + +def test_due_is_next_day_ten(paths: Paths) -> None: + assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0) + assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0) diff --git a/feedback_inbox/feedback_inbox/tests/test_cli.py b/feedback_inbox/feedback_inbox/tests/test_cli.py new file mode 100644 index 0000000..0cb01ac --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_cli.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The command line: run, sync, exit codes, lock.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from feedback_inbox import __main__ as entry +from feedback_inbox._cli import main +from feedback_inbox._inbox import MARKER +from feedback_inbox._paths import Paths, resolve_paths +from feedback_inbox.tests.test_support import FakeTodo, make_bundle + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + +def test_run_imports_and_exits_zero( + paths: Paths, caplog: pytest.LogCaptureFixture +) -> None: + bundle = make_bundle(paths.data_root) + todo = FakeTodo() + with caplog.at_level(logging.INFO): + assert main(["run", "--wait", "0"], client=todo) == 0 + assert (bundle / MARKER).exists() + assert len(todo.calls) == 1 + assert "imported" in caplog.text + assert main(["run", "--wait", "0"], client=todo) == 0 + assert len(todo.calls) == 1 + + +def test_run_fails_loudly_when_todo_is_down( + paths: Paths, caplog: pytest.LogCaptureFixture +) -> None: + bundle = make_bundle(paths.data_root) + assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1 + assert not (bundle / MARKER).exists() + assert "todo down" in caplog.text + + +def test_run_pending_is_not_an_error(paths: Paths) -> None: + make_bundle(paths.data_root, entry=False) + assert main(["run", "--wait", "0"], client=FakeTodo()) == 0 + + +def test_run_builds_the_real_client_from_paths(paths: Paths) -> None: + make_bundle(paths.data_root) + assert main(["run", "--wait", "0"]) == 1 + + +def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None: + assert main(["run", "--wait", "0"]) == 0 + + +def test_run_takes_the_lock_file(paths: Paths) -> None: + make_bundle(paths.data_root) + assert main(["run", "--wait", "0"], client=FakeTodo()) == 0 + assert paths.lock_file.exists() + + +def test_sync_command(paths: Paths) -> None: + make_bundle(paths.data_root) + calls: list[list[str]] = [] + + def fake(args: Sequence[str]) -> int: + calls.append(list(args)) + return 0 + + assert main(["sync"], systemctl=fake) == 0 + assert calls[0] == ["daemon-reload"] + + +def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None: + make_bundle(paths.data_root) + assert main(["sync"], systemctl=lambda _a: 1) == 1 + assert "daemon-reload" in caplog.text + + +def test_usage_error() -> None: + with pytest.raises(SystemExit): + main([]) + + +def test_real_paths_are_under_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path) + real = resolve_paths() + assert real.data_root == tmp_path / "data" + assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token" + assert real.unit_dir == tmp_path / ".config" / "systemd" / "user" + assert real.mcp_url == "http://127.0.0.1:8735/mcp" + given = Paths.under(tmp_path) + assert resolve_paths(given) is given + + +def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"]) + assert entry.run() == 0 diff --git a/feedback_inbox/feedback_inbox/tests/test_entry.py b/feedback_inbox/feedback_inbox/tests/test_entry.py new file mode 100644 index 0000000..42b662c --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_entry.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Finding a bundle's markdown entry and reading its report.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from feedback_inbox._entry import ( + find_entry, + game_and_combo, + note_from_entry, + read_report, +) +from feedback_inbox.tests.test_support import make_bundle + +if TYPE_CHECKING: + from pathlib import Path + + from feedback_inbox._paths import Paths + + +def test_find_entry_picks_the_matching_section(paths: Paths) -> None: + first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha") + second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta") + entry = find_entry(first.parent, second) + assert entry is not None + assert "beta" in entry + assert "alpha" not in entry + assert find_entry(first.parent, first) is not None + + +def test_find_entry_missing(paths: Paths) -> None: + bundle = make_bundle(paths.data_root, entry=False) + assert find_entry(bundle.parent, bundle) is None + (bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe") + assert find_entry(bundle.parent, bundle) is None + + +def test_read_report(paths: Paths) -> None: + bundle = make_bundle(paths.data_root, note=" hello \nworld") + report = read_report(bundle / "report.json") + assert report is not None + assert report.note == " hello \nworld" + assert (report.game, report.combo) == ("museum", "loop=P2") + + +def test_read_report_corrupt_forms(tmp_path: Path) -> None: + path = tmp_path / "report.json" + for bad in ("{broken", "[1]", '{"note": 3, "game": null}'): + path.write_text(bad, encoding="utf-8") + report = read_report(path) + if bad == '{"note": 3, "game": null}': + assert report is not None + assert (report.note, report.game, report.combo) == ("", "", "") + else: + assert report is None + path.write_bytes(b"\xff\xfe") + assert read_report(path) is None + + +def test_entry_fallbacks(paths: Paths) -> None: + bundle = make_bundle(paths.data_root, note="line one\nline two") + entry = find_entry(bundle.parent, bundle) + assert entry is not None + assert note_from_entry(entry) == "line one\nline two" + assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c") + assert note_from_entry("## head only") == "" + assert game_and_combo("nothing here") == ("", "") diff --git a/feedback_inbox/feedback_inbox/tests/test_inbox.py b/feedback_inbox/feedback_inbox/tests/test_inbox.py new file mode 100644 index 0000000..3abe774 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_inbox.py @@ -0,0 +1,194 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Import rules: complete, incomplete, idempotent, titles, due date, failures.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from feedback_inbox._entry import Report +from feedback_inbox._inbox import ( + MARKER, + Importer, + Outcome, + Waiter, + build_task, + run_once, + run_settled, +) +from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle + +if TYPE_CHECKING: + from pathlib import Path + + from feedback_inbox._paths import Paths + +NOW = 1_000_000.0 +OWNER = "kuhyx" +MANUAL = "MANUAL WORK REQUIRED: " + + +def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]: + return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)] + + +def test_complete_bundle_becomes_one_task(paths: Paths) -> None: + bundle = make_bundle(paths.data_root) + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] + ((repo, title, body),) = todo.calls + assert repo == "kuhyx/cleanup-plate" + assert ( + title + == MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL" + ) + assert body.startswith("Due: 2026-11-12 10:00\n\n## 2026-11-11T13:52:33") + assert f"{bundle}/report.json" in body + marker = json.loads((bundle / MARKER).read_text(encoding="utf-8")) + assert marker["task_id"] == "id0001" + "0" * 28 + assert marker["due"] == "2026-11-12T10:00:00" + + +def test_rerun_never_duplicates(paths: Paths) -> None: + make_bundle(paths.data_root) + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] + assert _run(paths, todo) == [Outcome.SKIPPED] + assert len(todo.calls) == 1 + + +def test_incomplete_bundles_wait(paths: Paths) -> None: + make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None) + make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False) + make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5) + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.PENDING] * 3 + assert todo.calls == [] + later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10) + assert [r.outcome for r in later] == [ + Outcome.PENDING, + Outcome.PENDING, + Outcome.IMPORTED, + ] + + +def test_multiple_games(paths: Paths) -> None: + make_bundle(paths.data_root, game="cleanup-plate", note="a") + make_bundle(paths.data_root, game="dread-grid", note="b") + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] * 2 + assert [(c[0], c[1]) for c in todo.calls] == [ + ("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"), + ("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"), + ] + + +def test_corrupt_report_still_a_task(paths: Paths) -> None: + bundle = make_bundle(paths.data_root, report="{broken", note="keep me") + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] + ((_, title, body),) = todo.calls + assert ( + title + == "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]" + ) + assert "report.json is corrupt" in body + assert (bundle / MARKER).exists() + + +def test_corrupt_report_without_note(paths: Paths) -> None: + make_bundle(paths.data_root, report="[]", note="") + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] + title = todo.calls[0][1] + game_part = "museum loop=P2,carry=d,id=c 13:52" + assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]" + + +def test_no_note_title_names_game_combo_time(paths: Paths) -> None: + make_bundle(paths.data_root, note="") + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] + assert ( + todo.calls[0][1] + == "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52" + ) + + +def test_title_uses_first_line_and_caps_length() -> None: + report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c") + draft = build_task("repo", STAMP, "## e", report) + assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…" + bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo="")) + assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52" + + +def test_todo_failure_leaves_no_marker(paths: Paths) -> None: + bundle = make_bundle(paths.data_root) + todo = FakeTodo(fail=True) + results = run_once(paths.data_root, Importer(todo, OWNER), NOW) + assert [r.outcome for r in results] == [Outcome.FAILED] + assert "todo down" in results[0].detail + assert not (bundle / MARKER).exists() + assert _run(paths, FakeTodo()) == [Outcome.IMPORTED] + + +def test_merge_is_flagged_but_marked(paths: Paths) -> None: + make_bundle(paths.data_root) + results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW) + assert results[0].outcome is Outcome.IMPORTED + assert "merged" in results[0].detail + + +def test_marker_write_failure_is_reported(paths: Paths) -> None: + bundle = make_bundle(paths.data_root) + (bundle / MARKER).mkdir() + results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW) + assert results[0].outcome is Outcome.SKIPPED + (bundle / MARKER).rmdir() + bundle.chmod(0o500) + try: + results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW) + finally: + bundle.chmod(0o700) + assert results[0].outcome is Outcome.FAILED + assert "marker" in results[0].detail + + +def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None: + make_bundle(paths.data_root, age=0.0, now=NOW) + clock = [NOW] + slept: list[float] = [] + + def sleep(seconds: float) -> None: + slept.append(seconds) + clock[0] += seconds + + todo = FakeTodo() + results = run_settled( + paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep) + ) + assert [r.outcome for r in results] == [Outcome.IMPORTED] + assert slept == [1.0, 1.0] + + other = tmp_path / "other" + other.mkdir() + make_bundle(other, report=None) + slept.clear() + results = run_settled( + other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep) + ) + assert [r.outcome for r in results] == [Outcome.PENDING] + assert slept == [1.0, 1.0, 1.0] + + +def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None: + make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a") + make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="") + make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{") + make_bundle(paths.data_root, game="dread-grid", note="b") + todo = FakeTodo() + assert _run(paths, todo) == [Outcome.IMPORTED] * 4 + titles = [call[1] for call in todo.calls] + assert len(titles) == 4 + assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles) diff --git a/feedback_inbox/feedback_inbox/tests/test_support.py b/feedback_inbox/feedback_inbox/tests/test_support.py new file mode 100644 index 0000000..f407844 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_support.py @@ -0,0 +1,82 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Builders for fake feedback bundles and a fake todo client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +import os +from typing import TYPE_CHECKING + +from feedback_inbox._todo import TaskRef, TodoError + +if TYPE_CHECKING: + from pathlib import Path + +STAMP = "2026-11-11T13-52-33-162" +NOTE = "I would like to see the painting IN FULL\n\nsecond paragraph" + + +def entry_text(bundle: Path, note: str = NOTE, game: str = "museum") -> str: + """The markdown entry a game appends for ``bundle``.""" + head = note.splitlines()[0][:70] if note else "" + parts = [f"## 2026-11-11T13:52:33: {head}", ""] + if note: + parts += [note, ""] + parts += [ + f"- Screenshot: `{bundle}/annotated.png`", + f"- Report: `{bundle}/report.json`", + f"- Game `{game}`, combo `loop=P2,carry=d,id=c`, build abc, session `s`", + "", + "Task: Read the annotated screenshot and fix it.", + "", + ] + return "\n".join(parts) + + +def make_bundle( + data_root: Path, + *, + game: str = "cleanup-plate", + stamp: str = STAMP, + note: str = NOTE, + report: str | None = "ok", + entry: bool = True, + age: float = 60.0, + now: float = 1_000_000.0, +) -> Path: + """Create ``_binaries/feedback//`` and its md entry.""" + feedback = data_root / f"{game}_binaries" / "feedback" + bundle = feedback / stamp + bundle.mkdir(parents=True, exist_ok=True) + if report is not None: + path = bundle / "report.json" + if report == "ok": + body = json.dumps({"note": note, "game": "museum", "combo": "loop=P2"}) + else: + body = report + path.write_text(body, encoding="utf-8") + os.utime(path, (now - age, now - age)) + if entry: + md = feedback / f"feedback-{stamp[:10]}.md" + with md.open("a", encoding="utf-8") as handle: + handle.write(entry_text(bundle, note)) + return bundle + + +@dataclass +class FakeTodo: + """Records add_task calls; optionally fails or reports a merge.""" + + calls: list[tuple[str, str, str]] = field(default_factory=list) + fail: bool = False + merged: bool = False + + def add_task(self, repo: str, title: str, body: str) -> TaskRef: + if self.fail: + msg = "todo down" + raise TodoError(msg) + self.calls.append((repo, title, body)) + return TaskRef( + task_id=f"id{len(self.calls):04d}" + "0" * 28, merged=self.merged + ) diff --git a/feedback_inbox/feedback_inbox/tests/test_todo.py b/feedback_inbox/feedback_inbox/tests/test_todo.py new file mode 100644 index 0000000..e248da8 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_todo.py @@ -0,0 +1,184 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The MCP client against a real local HTTP server (never the live todo app).""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import threading +from typing import TYPE_CHECKING, ClassVar + +import pytest + +from feedback_inbox._todo import McpTodoClient, TodoError + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +FULL_ID = "a1b2c3d4" + "e" * 28 + + +class _Handler(BaseHTTPRequestHandler): + seen: ClassVar[list[dict[str, object]]] = [] + headers_seen: ClassVar[list[dict[str, str]]] = [] + tool_text: ClassVar[str] = f"added {FULL_ID}\nline" + is_error: ClassVar[bool] = False + status: ClassVar[int] = 200 + sse: ClassVar[bool] = True + + def do_POST(self) -> None: + length = int(self.headers["Content-Length"]) + body = json.loads(self.rfile.read(length)) + type(self).seen.append(body) + type(self).headers_seen.append({k.lower(): v for k, v in self.headers.items()}) + if type(self).status != 200: + self.send_response(type(self).status) + self.end_headers() + return + self.send_response(200) + self.send_header("mcp-session-id", "sess-1") + self.end_headers() + method = body.get("method") + if method == "initialize": + self.wfile.write( + b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n' + ) + elif method == "tools/call": + result: dict[str, object] = { + "content": [{"type": "text", "text": type(self).tool_text}], + } + if type(self).is_error: + result["isError"] = True + reply = json.dumps({"jsonrpc": "2.0", "id": 2, "result": result}) + out = f"event: message\ndata: {reply}\n\n" if type(self).sse else reply + self.wfile.write(out.encode()) + + def log_message(self, *_args: object) -> None: + return + + +@pytest.fixture +def server() -> Iterator[str]: + _Handler.seen = [] + _Handler.headers_seen = [] + _Handler.tool_text = f"added {FULL_ID}\nline" + _Handler.is_error = False + _Handler.status = 200 + _Handler.sse = True + httpd = HTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{httpd.server_port}/mcp" + httpd.shutdown() + httpd.server_close() + thread.join() + + +def _client(url: str, tmp_path: Path) -> McpTodoClient: + token = tmp_path / "token" + token.write_text("secret-token\n", encoding="utf-8") + return McpTodoClient(url, token) + + +def test_add_task_round_trip(server: str, tmp_path: Path) -> None: + ref = _client(server, tmp_path).add_task("kuhyx/cleanup-plate", "t", "b") + assert (ref.task_id, ref.merged) == (FULL_ID, False) + methods = [m.get("method") for m in _Handler.seen] + assert methods == ["initialize", "notifications/initialized", "tools/call"] + call = _Handler.seen[2]["params"] + assert call == { + "name": "add_task", + "arguments": { + "repo": "kuhyx/cleanup-plate", + "title": "t", + "body": "b", + "new_note": True, + }, + } + assert _Handler.headers_seen[0]["authorization"] == "Bearer secret-token" + assert _Handler.headers_seen[2]["mcp-session-id"] == "sess-1" + + +def test_plain_json_reply_and_merge(server: str, tmp_path: Path) -> None: + _Handler.sse = False + _Handler.tool_text = f"merged into {FULL_ID}\nstart: skipped" + ref = _client(server, tmp_path).add_task("r/x", "t", "b") + assert (ref.task_id, ref.merged) == (FULL_ID, True) + + +def test_tool_error_is_reported(server: str, tmp_path: Path) -> None: + _Handler.is_error = True + _Handler.tool_text = "boom" + with pytest.raises(TodoError, match="boom"): + _client(server, tmp_path).add_task("r/x", "t", "b") + + +def test_unexpected_reply_text(server: str, tmp_path: Path) -> None: + _Handler.tool_text = "who knows" + with pytest.raises(TodoError, match="who knows"): + _client(server, tmp_path).add_task("r/x", "t", "b") + + +def test_http_error(server: str, tmp_path: Path) -> None: + _Handler.status = 403 + with pytest.raises(TodoError, match="403"): + _client(server, tmp_path).add_task("r/x", "t", "b") + + +def test_unreachable_server(tmp_path: Path) -> None: + with pytest.raises(TodoError, match="unreachable"): + _client("http://127.0.0.1:9/mcp", tmp_path).add_task("r/x", "t", "b") + + +def test_missing_token(tmp_path: Path) -> None: + client = McpTodoClient("http://127.0.0.1:9/mcp", tmp_path / "none") + with pytest.raises(TodoError, match="token"): + client.add_task("r/x", "t", "b") + + +def test_missing_session_header(server: str, tmp_path: Path) -> None: + class _NoSession(_Handler): + def do_POST(self) -> None: + self.rfile.read(int(self.headers["Content-Length"])) + self.send_response(200) + self.end_headers() + self.wfile.write(b"{}") + + httpd = HTTPServer(("127.0.0.1", 0), _NoSession) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path) + with pytest.raises(TodoError, match="session"): + client.add_task("r/x", "t", "b") + finally: + httpd.shutdown() + httpd.server_close() + thread.join() + + +def test_garbage_json(server: str, tmp_path: Path) -> None: + _Handler.sse = False + _Handler.tool_text = "x" + + class _Bad(_Handler): + def do_POST(self) -> None: + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + self.send_response(200) + self.send_header("mcp-session-id", "s") + self.end_headers() + if body.get("method") == "tools/call": + self.wfile.write(b"not json") + + httpd = HTTPServer(("127.0.0.1", 0), _Bad) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path) + with pytest.raises(TodoError, match="unparsable"): + client.add_task("r/x", "t", "b") + finally: + httpd.shutdown() + httpd.server_close() + thread.join() diff --git a/feedback_inbox/feedback_inbox/tests/test_units.py b/feedback_inbox/feedback_inbox/tests/test_units.py new file mode 100644 index 0000000..e0ec997 --- /dev/null +++ b/feedback_inbox/feedback_inbox/tests/test_units.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The path-unit drop-ins follow the set of game folders, and only then reload.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from feedback_inbox._units import ( + SYNC_PATH, + WATCH_PATH, + desired, + run_systemctl, + sync_units, +) +from feedback_inbox.tests.test_support import make_bundle + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from feedback_inbox._paths import Paths + + +class _Systemctl: + def __init__(self, code: int = 0) -> None: + self.calls: list[list[str]] = [] + self.code = code + + def __call__(self, args: Sequence[str]) -> int: + self.calls.append(list(args)) + return self.code + + +def test_desired_lists_feedback_and_binaries_dirs(paths: Paths) -> None: + make_bundle(paths.data_root, game="cleanup-plate") + (paths.data_root / "dread-grid_binaries").mkdir() + wanted = desired(paths.data_root, paths.unit_dir) + watch = wanted[paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf"] + sync = wanted[paths.unit_dir / f"{SYNC_PATH}.d" / "dirs.conf"] + feedback = paths.data_root / "cleanup-plate_binaries" / "feedback" + assert f"PathChanged={feedback}\n" in watch + assert "dread-grid" not in watch + assert f"PathChanged={paths.data_root}/cleanup-plate_binaries\n" in sync + assert f"PathChanged={paths.data_root}/dread-grid_binaries\n" in sync + + +def test_sync_writes_reloads_and_restarts_once(paths: Paths) -> None: + make_bundle(paths.data_root) + ctl = _Systemctl() + assert sync_units(paths.data_root, paths.unit_dir, ctl) is True + assert ctl.calls == [ + ["daemon-reload"], + ["restart", WATCH_PATH, SYNC_PATH], + ] + again = _Systemctl() + assert sync_units(paths.data_root, paths.unit_dir, again) is False + assert again.calls == [] + + +def test_new_game_triggers_a_rewrite(paths: Paths) -> None: + make_bundle(paths.data_root, game="cleanup-plate") + sync_units(paths.data_root, paths.unit_dir, _Systemctl()) + make_bundle(paths.data_root, game="dread-grid") + ctl = _Systemctl() + assert sync_units(paths.data_root, paths.unit_dir, ctl) is True + text = (paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf").read_text( + encoding="utf-8" + ) + assert "dread-grid_binaries/feedback" in text + + +def test_systemctl_failure_is_an_error(paths: Paths) -> None: + make_bundle(paths.data_root) + ctl = _Systemctl(code=1) + with pytest.raises(RuntimeError, match="daemon-reload"): + sync_units(paths.data_root, paths.unit_dir, ctl) + assert ctl.calls == [["daemon-reload"]] + + +def test_run_systemctl_uses_the_user_manager( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + seen: list[list[str]] = [] + + class _Done: + returncode = 3 + + def fake_run(cmd: list[str], *, check: bool) -> _Done: + assert check is False + seen.append(cmd) + return _Done() + + monkeypatch.setattr("feedback_inbox._units.subprocess.run", fake_run) + assert run_systemctl(["restart", "x"]) == 3 + assert seen == [["/usr/bin/systemctl", "--user", "restart", "x"]] diff --git a/feedback_inbox/install.sh b/feedback_inbox/install.sh new file mode 100755 index 0000000..76e1175 --- /dev/null +++ b/feedback_inbox/install.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# ============================================================================ +# Install feedback-inbox for the current user: its own venv, the CLI on PATH, +# a todo-server token, and the systemd user units that watch every game's +# ~/data/_binaries/feedback/ folder. Idempotent; rerun after any change. +# +# Enabling the units runs the inbox once (the sweep timer fires at once), so +# every bundle already on disk becomes a task: that is the backfill. +# Run the test suite first (scripts/run_subproject_tests.sh feedback_inbox). +# ============================================================================ + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_DIR +readonly STATE_DIR="${HOME}/.local/share/feedback-inbox" +readonly VENV_DIR="${STATE_DIR}/venv" +readonly BIN_DIR="${HOME}/.local/bin" +readonly UNIT_DIR="${HOME}/.config/systemd/user" +readonly CONFIG_DIR="${HOME}/.config/feedback-inbox" +readonly TOKEN_FILE="${CONFIG_DIR}/token" +readonly MINT="${HOME}/src/todo/mcp/mint_token.sh" +readonly SYSTEM_PYTHON="/usr/bin/python3" +readonly UNITS=( + feedback-inbox.service feedback-inbox.path + feedback-inbox-sync.service feedback-inbox-sync.path feedback-inbox-sync.timer +) + +log() { printf 'install: %s\n' "$1" >&2; } +fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; } + +build_venv() { + log "building the venv at $VENV_DIR" + mkdir -p "$STATE_DIR" + [[ -x "$VENV_DIR/bin/python" ]] || "$SYSTEM_PYTHON" -m venv "$VENV_DIR" \ + || fail "could not create the venv" + "$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \ + || fail "feedback-inbox itself (stdlib only, no other packages)" + mkdir -p "$BIN_DIR" + ln -sf "$VENV_DIR/bin/feedback-inbox" "$BIN_DIR/feedback-inbox" +} + +ensure_token() { + # The service has no shell, so TODO_MCP_TOKEN from ~/.zshenv is invisible + # to it. It gets its own revocable token (delete its line in + # ~/.config/todo-mcp/tokens); the value goes straight to a 0600 file. + [[ -s "$TOKEN_FILE" ]] && return 0 + [[ -x "$MINT" ]] || fail "$MINT is missing (the todo repo)" + log "minting a todo-server token for feedback-inbox" + mkdir -p "$CONFIG_DIR" + (umask 077 && "$MINT" feedback-inbox >"$TOKEN_FILE") || fail "token mint" + [[ -s "$TOKEN_FILE" ]] || fail "token file is empty" +} + +install_units() { + log "installing and enabling the units" + mkdir -p "$UNIT_DIR" + local unit + for unit in "${UNITS[@]}"; do + install -m 644 "$REPO_DIR/systemd/$unit" "$UNIT_DIR/" + done + systemctl --user daemon-reload || fail "daemon-reload" + # Drop-ins first, so the paths are armed with every current game folder. + "$VENV_DIR/bin/feedback-inbox" sync || fail "writing the watch lists" + systemctl --user enable --now feedback-inbox.path feedback-inbox-sync.path \ + feedback-inbox-sync.timer || fail "enabling the units" +} + +verify() { + local unit + for unit in feedback-inbox.path feedback-inbox-sync.path feedback-inbox-sync.timer; do + systemctl --user is-active --quiet "$unit" || fail "$unit is not active" + done + log "ok: watching $(systemctl --user show feedback-inbox.path -p PathChanged \ + | tr ';' '\n' | grep -c feedback) feedback folder(s)" +} + +main() { + build_venv + ensure_token + install_units + verify +} + +main "$@" diff --git a/feedback_inbox/pyproject.toml b/feedback_inbox/pyproject.toml new file mode 100644 index 0000000..4d7d580 --- /dev/null +++ b/feedback_inbox/pyproject.toml @@ -0,0 +1,175 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "feedback-inbox" +version = "0.1.0" +description = "Turns every game dev-feedback bundle into one todo-app task" +requires-python = ">=3.14" +dependencies = [] + +[project.scripts] +feedback-inbox = "feedback_inbox._cli:main" + +[tool.setuptools.packages.find] +include = ["feedback_inbox*"] + +[tool.ruff] +target-version = "py314" +include = ["*.py", "**/*.py"] +exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D203", + "D213", + "COM812", + "ISC001", + "S603", + # Stamps are local wall-clock names the games write; naive on purpose. + "DTZ001", + "DTZ007", + # The todo server is loopback http by design (MCP_URL), never user input. + "S310", +] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"] +"**/test_*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +force-single-line = false +force-sort-within-sections = true +known-first-party = ["feedback_inbox"] + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = true + +[tool.mypy] +python_version = "3.14" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +disallow_any_unimported = true +disallow_any_explicit = false +disallow_any_generics = true +disallow_subclassing_any = true +strict_equality = true +extra_checks = true +ignore_missing_imports = true +show_error_codes = true +color_output = true +exclude = [".venv/"] + +[tool.pylint.main] +# The repo root on the path, so pylint resolves the package's own imports the +# same way pytest does instead of reporting import-error for them. +init-hook = "import sys; sys.path.insert(0, 'feedback_inbox')" +analyse-fallback-blocks = true +persistent = true +jobs = 0 +py-version = "3.14" +ignore = [".venv", "__pycache__"] +# Test suites intentionally use patterns (protected-access, fixture-arg +# shadowing) that don't apply to source code; ruff's per-file-ignores carry +# the equivalent exemptions, so tests stay linted -- by ruff. This is the same +# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has +# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the +# basename of what pylint is *given*, and pre-commit hands it explicit file +# paths, so a directory name in `ignore` never fires under the hook. +ignore-paths = ["^(.*/)?tests/.*$"] +ignore-patterns = [".*\\.pyi$"] + +[tool.pylint.messages_control] +enable = "all" +disable = [] + +[tool.pylint.design] +min-public-methods = 0 +max-module-lines = 1000 +max-attributes = 10 + +[tool.pylint.typecheck] +generated-members = [ + ".*\\.assert_called_once_with", + ".*\\.assert_called_once", + ".*\\.assert_called", + ".*\\.assert_not_called", + ".*\\.assert_any_call", + ".*\\.call_args", + ".*\\.call_args_list", + ".*\\.call_count", +] + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] + +[tool.pytest.ini_options] +testpaths = ["feedback_inbox/tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "-ra", + "--cov=feedback_inbox", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=lcov", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + "default::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["feedback_inbox"] +branch = true +omit = [ + "*/__pycache__/*", + "*/tests/*", + "*/.venv/*", +] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "raise AssertionError", + "if TYPE_CHECKING:", + 'if __name__ == "__main__":', +] +partial_branches = ["pragma: no branch"] diff --git a/feedback_inbox/requirements.txt b/feedback_inbox/requirements.txt new file mode 100644 index 0000000..b7f78b1 --- /dev/null +++ b/feedback_inbox/requirements.txt @@ -0,0 +1,10 @@ +# Exact pins, enforced by the shared dependency-freshness gate: a `>=` +# floor is satisfied by anything newer, so it tells a checker nothing +# about whether this repo is actually current. +bandit[toml]==1.9.4 +mypy==2.4.1 +pre-commit==4.7.0 +pylint==4.1.3 +pytest==9.2.0 +pytest-cov==7.1.0 +ruff==0.17.4 diff --git a/feedback_inbox/systemd/feedback-inbox-sync.path b/feedback_inbox/systemd/feedback-inbox-sync.path new file mode 100644 index 0000000..6b0e6f4 --- /dev/null +++ b/feedback_inbox/systemd/feedback-inbox-sync.path @@ -0,0 +1,12 @@ +[Unit] +Description=feedback-inbox: a game folder appeared or went away + +[Path] +# ~/data fires when a *_binaries folder is created; the generated drop-in +# feedback-inbox-sync.path.d/dirs.conf adds each existing *_binaries folder, so +# a game's later `feedback/` subfolder is seen too. +PathChanged=%h/data +Unit=feedback-inbox-sync.service + +[Install] +WantedBy=default.target diff --git a/feedback_inbox/systemd/feedback-inbox-sync.service b/feedback_inbox/systemd/feedback-inbox-sync.service new file mode 100644 index 0000000..594bb56 --- /dev/null +++ b/feedback_inbox/systemd/feedback-inbox-sync.service @@ -0,0 +1,12 @@ +[Unit] +Description=feedback-inbox: re-arm the watches for new games, then import +Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md +StartLimitIntervalSec=0 + +[Service] +Type=oneshot +WorkingDirectory=/ +ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox sync +ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 0 +Nice=10 +TimeoutStartSec=2min diff --git a/feedback_inbox/systemd/feedback-inbox-sync.timer b/feedback_inbox/systemd/feedback-inbox-sync.timer new file mode 100644 index 0000000..c920327 --- /dev/null +++ b/feedback_inbox/systemd/feedback-inbox-sync.timer @@ -0,0 +1,12 @@ +[Unit] +Description=feedback-inbox: fallback sweep for events the path units missed + +[Timer] +# Events that land while a run is active are lost by .path units; this catches +# them. No Persistent=: a missed sweep is harmless, the next one runs soon. +OnBootSec=2min +OnUnitActiveSec=5min +Unit=feedback-inbox-sync.service + +[Install] +WantedBy=timers.target diff --git a/feedback_inbox/systemd/feedback-inbox.path b/feedback_inbox/systemd/feedback-inbox.path new file mode 100644 index 0000000..98cfc83 --- /dev/null +++ b/feedback_inbox/systemd/feedback-inbox.path @@ -0,0 +1,12 @@ +[Unit] +Description=feedback-inbox: watch every game's feedback folder + +[Path] +# systemd paths take no globs. The folders themselves are listed in the +# generated drop-in feedback-inbox.path.d/dirs.conf (`feedback-inbox sync`); +# this baseline is the token file, which exists once installed and never churns. +PathChanged=%h/.config/feedback-inbox/token +Unit=feedback-inbox.service + +[Install] +WantedBy=default.target diff --git a/feedback_inbox/systemd/feedback-inbox.service b/feedback_inbox/systemd/feedback-inbox.service new file mode 100644 index 0000000..8fedb0a --- /dev/null +++ b/feedback_inbox/systemd/feedback-inbox.service @@ -0,0 +1,15 @@ +[Unit] +Description=feedback-inbox: turn new game feedback bundles into todo tasks +Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md +# The .path unit fires on every write in a feedback folder; bursts (a bundle is +# written in pieces) must not trip the default start limit. +StartLimitIntervalSec=0 + +[Service] +Type=oneshot +WorkingDirectory=/ +# Waits up to 15 s for a half-written bundle to finish before leaving it to the +# timer. Exits non-zero when the todo server refuses, so the failure is visible. +ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 15 +Nice=10 +TimeoutStartSec=2min