200 lines
6.0 KiB
Python
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
|