Files
redmine/redmine_ticket_filters.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

200 lines
6.0 KiB
Python

#!/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:
<queues_root>/<name>/
12345.json -> <cache>/<project>/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