265 lines
9.2 KiB
Python
265 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolve human-friendly Redmine names to IDs for set-fields / mutations.
|
|
|
|
Caches project metadata under <cache-dir>/meta/<project>/ so agents can use
|
|
--status Closed instead of --status-id 5 without hammering the API every run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def meta_dir(cache_dir: Path, project: str) -> Path:
|
|
path = Path(cache_dir) / "meta" / project
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _save_json(path: Path, data: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def _fresh(path: Path, max_age_seconds: int) -> bool:
|
|
if not path.is_file():
|
|
return False
|
|
age = time.time() - path.stat().st_mtime
|
|
return age <= max_age_seconds
|
|
|
|
|
|
def normalize_name(value: str) -> str:
|
|
return " ".join(str(value).strip().lower().split())
|
|
|
|
|
|
def fetch_issue_statuses(client: Any) -> list[dict[str, Any]]:
|
|
data = client.request("GET", "/issue_statuses.json")
|
|
statuses = data.get("issue_statuses") if isinstance(data, dict) else None
|
|
return list(statuses) if isinstance(statuses, list) else []
|
|
|
|
|
|
def fetch_issue_priorities(client: Any) -> list[dict[str, Any]]:
|
|
data = client.request("GET", "/enumerations/issue_priorities.json")
|
|
priorities = data.get("issue_priorities") if isinstance(data, dict) else None
|
|
return list(priorities) if isinstance(priorities, list) else []
|
|
|
|
|
|
def fetch_project_categories(client: Any, project: str) -> list[dict[str, Any]]:
|
|
data = client.request("GET", f"/projects/{project}/issue_categories.json")
|
|
cats = data.get("issue_categories") if isinstance(data, dict) else None
|
|
return list(cats) if isinstance(cats, list) else []
|
|
|
|
|
|
def fetch_project_memberships(client: Any, project: str) -> list[dict[str, Any]]:
|
|
"""Memberships include nested user objects for assignee resolution."""
|
|
members: list[dict[str, Any]] = []
|
|
offset = 0
|
|
total = None
|
|
while total is None or offset < total:
|
|
data = client.request(
|
|
"GET",
|
|
f"/projects/{project}/memberships.json",
|
|
{"limit": 100, "offset": offset},
|
|
)
|
|
page = data.get("memberships") if isinstance(data, dict) else []
|
|
if not isinstance(page, list):
|
|
page = []
|
|
members.extend(page)
|
|
total = int(data.get("total_count", len(members))) if isinstance(data, dict) else len(members)
|
|
if not page:
|
|
break
|
|
offset += len(page)
|
|
return members
|
|
|
|
|
|
def users_from_memberships(memberships: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
users: list[dict[str, Any]] = []
|
|
seen: set[int] = set()
|
|
for m in memberships:
|
|
user = m.get("user") if isinstance(m, dict) else None
|
|
if not isinstance(user, dict) or user.get("id") is None:
|
|
continue
|
|
uid = int(user["id"])
|
|
if uid in seen:
|
|
continue
|
|
seen.add(uid)
|
|
users.append(
|
|
{
|
|
"id": uid,
|
|
"name": user.get("name") or f"{user.get('firstname', '')} {user.get('lastname', '')}".strip(),
|
|
"login": user.get("login"),
|
|
}
|
|
)
|
|
return users
|
|
|
|
|
|
def refresh_meta_cache(
|
|
client: Any,
|
|
cache_dir: Path,
|
|
project: str,
|
|
*,
|
|
force: bool = False,
|
|
max_age_seconds: int = 6 * 3600,
|
|
) -> dict[str, Path]:
|
|
root = meta_dir(cache_dir, project)
|
|
paths = {
|
|
"statuses": root / "issue_statuses.json",
|
|
"priorities": root / "issue_priorities.json",
|
|
"categories": root / "issue_categories.json",
|
|
"users": root / "project_users.json",
|
|
}
|
|
if not force and all(_fresh(p, max_age_seconds) for p in paths.values()):
|
|
return paths
|
|
|
|
statuses = fetch_issue_statuses(client)
|
|
priorities = fetch_issue_priorities(client)
|
|
categories = fetch_project_categories(client, project)
|
|
memberships = fetch_project_memberships(client, project)
|
|
users = users_from_memberships(memberships)
|
|
|
|
_save_json(paths["statuses"], {"fetched_at": now_iso(), "issue_statuses": statuses})
|
|
_save_json(paths["priorities"], {"fetched_at": now_iso(), "issue_priorities": priorities})
|
|
_save_json(paths["categories"], {"fetched_at": now_iso(), "issue_categories": categories})
|
|
_save_json(paths["users"], {"fetched_at": now_iso(), "users": users})
|
|
return paths
|
|
|
|
|
|
def _items_from_file(path: Path, key: str) -> list[dict[str, Any]]:
|
|
data = _load_json(path) or {}
|
|
items = data.get(key)
|
|
return list(items) if isinstance(items, list) else []
|
|
|
|
|
|
def resolve_id_by_name(
|
|
items: list[dict[str, Any]],
|
|
name: str,
|
|
*,
|
|
id_key: str = "id",
|
|
name_keys: tuple[str, ...] = ("name",),
|
|
) -> int:
|
|
target = normalize_name(name)
|
|
if target.isdigit():
|
|
return int(target)
|
|
matches: list[dict[str, Any]] = []
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
candidates = []
|
|
for nk in name_keys:
|
|
val = item.get(nk)
|
|
if val is not None:
|
|
candidates.append(normalize_name(str(val)))
|
|
# also full first+last style
|
|
if item.get("firstname") or item.get("lastname"):
|
|
candidates.append(
|
|
normalize_name(f"{item.get('firstname', '')} {item.get('lastname', '')}")
|
|
)
|
|
if any(c == target for c in candidates if c):
|
|
matches.append(item)
|
|
continue
|
|
# substring match as fallback
|
|
if any(target in c for c in candidates if c):
|
|
matches.append(item)
|
|
if not matches:
|
|
available = []
|
|
for item in items[:30]:
|
|
if isinstance(item, dict):
|
|
available.append(str(item.get("name") or item.get("login") or item.get(id_key)))
|
|
raise LookupError(
|
|
f"No match for {name!r}. Examples: {', '.join(available) or '(empty meta cache)'}"
|
|
)
|
|
if len(matches) > 1:
|
|
# prefer exact name over substring
|
|
exact = [
|
|
m
|
|
for m in matches
|
|
if any(normalize_name(str(m.get(k) or "")) == target for k in name_keys)
|
|
or normalize_name(f"{m.get('firstname', '')} {m.get('lastname', '')}") == target
|
|
]
|
|
if len(exact) == 1:
|
|
return int(exact[0][id_key])
|
|
labels = [str(m.get("name") or m.get("login") or m.get(id_key)) for m in matches[:8]]
|
|
raise LookupError(f"Ambiguous match for {name!r}: {', '.join(labels)}")
|
|
return int(matches[0][id_key])
|
|
|
|
|
|
def load_meta_lists(cache_dir: Path, project: str) -> dict[str, list[dict[str, Any]]]:
|
|
root = meta_dir(cache_dir, project)
|
|
return {
|
|
"statuses": _items_from_file(root / "issue_statuses.json", "issue_statuses"),
|
|
"priorities": _items_from_file(root / "issue_priorities.json", "issue_priorities"),
|
|
"categories": _items_from_file(root / "issue_categories.json", "issue_categories"),
|
|
"users": _items_from_file(root / "project_users.json", "users"),
|
|
}
|
|
|
|
|
|
def resolve_field_names(
|
|
cache_dir: Path,
|
|
project: str,
|
|
*,
|
|
status_name: str | None = None,
|
|
assignee_name: str | None = None,
|
|
priority_name: str | None = None,
|
|
category_name: str | None = None,
|
|
client: Any | None = None,
|
|
refresh: bool = False,
|
|
) -> dict[str, int]:
|
|
"""Return numeric id fields from optional name strings.
|
|
|
|
If meta cache is empty and client is provided, refresh automatically.
|
|
"""
|
|
lists = load_meta_lists(cache_dir, project)
|
|
need_refresh = refresh or (
|
|
client is not None
|
|
and (
|
|
(status_name and not lists["statuses"])
|
|
or (assignee_name and not lists["users"])
|
|
or (priority_name and not lists["priorities"])
|
|
or (category_name and not lists["categories"])
|
|
)
|
|
)
|
|
if need_refresh and client is not None:
|
|
refresh_meta_cache(client, cache_dir, project, force=True)
|
|
lists = load_meta_lists(cache_dir, project)
|
|
|
|
out: dict[str, int] = {}
|
|
if status_name:
|
|
out["status_id"] = resolve_id_by_name(lists["statuses"], status_name)
|
|
if priority_name:
|
|
out["priority_id"] = resolve_id_by_name(lists["priorities"], priority_name)
|
|
if category_name:
|
|
if normalize_name(category_name) in {"", "none", "clear", "null"}:
|
|
out["category_id"] = 0
|
|
else:
|
|
out["category_id"] = resolve_id_by_name(lists["categories"], category_name)
|
|
if assignee_name:
|
|
if normalize_name(assignee_name) in {"", "none", "unassigned", "nobody", "null", "0"}:
|
|
out["assigned_to_id"] = 0
|
|
else:
|
|
out["assigned_to_id"] = resolve_id_by_name(
|
|
lists["users"],
|
|
assignee_name,
|
|
name_keys=("name", "login"),
|
|
)
|
|
return out
|