Organized
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user