#!/usr/bin/env python3 """Resolve human-friendly Redmine names to IDs for set-fields / mutations. Caches project metadata under /meta// 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