#!/usr/bin/env python3 """Saved property filters and filesystem queue materialization. Saved filters live as text files under a directory (default: ./saved-filters). Each file contains one property expression (same language as search --where). Comments (# ...) and blank lines are ignored; remaining lines are joined with AND unless the file is a single multi-line expression with parentheses. Load by: - basename without extension: helpdeskEmailMatchCompany - alias.helpdeskEmailMatchCompany - @helpdeskEmailMatchCompany - path/to/file.expr """ from __future__ import annotations import json import os from pathlib import Path from typing import Any, Iterable from redmine_ticket_query import QueryError, filter_by_query DEFAULT_SAVED_FILTERS_DIR = Path("saved-filters") DEFAULT_QUEUES_DIR_NAME = "queues" def default_saved_filters_dir(repo_root: Path | None = None) -> Path: root = repo_root or Path.cwd() return root / "saved-filters" def strip_filter_source(text: str) -> str: lines: list[str] = [] for raw in text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue lines.append(line) return " ".join(lines).strip() def resolve_filter_name(raw: str) -> str: name = raw.strip() if name.startswith("alias."): name = name[len("alias.") :] if name.startswith("@"): name = name[1:] return name.strip() def load_saved_filter_expression(name_or_path: str, search_dirs: Iterable[Path]) -> str: """Load expression from a saved filter name or filesystem path.""" raw = name_or_path.strip() path = Path(raw) candidates: list[Path] = [] if path.is_file(): candidates.append(path) else: name = resolve_filter_name(raw) for directory in search_dirs: for ext in (".expr", ".filter", ".wtf", ".txt", ""): candidate = directory / f"{name}{ext}" if candidate.is_file(): candidates.append(candidate) break if candidates: break if not candidates: searched = ", ".join(str(d) for d in search_dirs) raise QueryError(f"Saved filter not found: {name_or_path!r} (looked in {searched})") expression = strip_filter_source(candidates[0].read_text(encoding="utf-8")) if not expression: raise QueryError(f"Saved filter file is empty: {candidates[0]}") return expression def expand_where_list( where_items: list[str] | None, saved_dirs: Iterable[Path], ) -> list[str]: """Expand @alias / alias.name / bare saved names; leave plain expressions as-is. Heuristic: if the string starts with @ or alias. or is a single identifier matching a saved file, load it; otherwise treat as inline expression. """ out: list[str] = [] for item in where_items or []: text = item.strip() if not text: continue if text.startswith("@") or text.startswith("alias."): out.append(load_saved_filter_expression(text, saved_dirs)) continue # bare identifier → try saved filter first if re_fullmatch_identifier(text): try: out.append(load_saved_filter_expression(text, saved_dirs)) continue except QueryError: pass out.append(text) return out def re_fullmatch_identifier(text: str) -> bool: import re return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", text)) def load_queue_definitions(path: Path) -> list[dict[str, Any]]: """Load queue definitions from JSON. Format: { "queues": [ { "name": "customer-service-triage-needed", "where": ["assigned_to IS NULL", "category.name IS NULL"], "max_tickets": 200, "order": "newest-updated-first" } ] } """ data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, list): queues = data elif isinstance(data, dict): queues = data.get("queues") or data.get("filters") or [] else: raise ValueError(f"Invalid queue config: {path}") if not isinstance(queues, list): raise ValueError(f"queues must be a list in {path}") return [q for q in queues if isinstance(q, dict) and q.get("name")] def materialize_queue( *, name: str, issues: list[dict[str, Any]], cache_dir: Path, project: str, queues_root: Path, ) -> dict[str, Any]: """Create/refresh a directory of symlinks to matching ticket JSON files. Layout: // 12345.json -> //12345.json ... .queue-manifest.json """ queue_dir = queues_root / name queue_dir.mkdir(parents=True, exist_ok=True) # Remove old ticket symlinks (keep non-matching cleaned) for existing in queue_dir.glob("*.json"): if existing.name.startswith("."): continue if existing.is_symlink() or existing.is_file(): existing.unlink() project_dir = Path(cache_dir) / project linked = 0 missing = 0 for issue in issues: issue_id = issue.get("id") if issue_id is None: continue target = project_dir / f"{issue_id}.json" link_path = queue_dir / f"{issue_id}.json" if not target.is_file(): missing += 1 continue # relative symlink when possible try: rel = os.path.relpath(target, start=queue_dir) link_path.symlink_to(rel) except OSError: link_path.symlink_to(target) linked += 1 manifest = { "name": name, "project": project, "cache_dir": str(cache_dir), "linked": linked, "missing_source": missing, "issue_ids": [int(i["id"]) for i in issues if i.get("id") is not None], } (queue_dir / ".queue-manifest.json").write_text( json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) return manifest