#!/usr/bin/env python3 """Small CLI for searching and lightly updating Redmine CRM contacts.""" from __future__ import annotations import argparse import json import os import re import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from datetime import datetime, timezone from difflib import SequenceMatcher from pathlib import Path from typing import Any, Iterable DEFAULT_BASE_URL = "http://192.168.50.170" DEFAULT_PROJECT = "customer-service" DEFAULT_CACHE_DIR = Path(".cache/redmine_contacts") SEARCH_EXAMPLES_DOC = "docs/redmine_contacts_search.md" SEARCH_EXAMPLES = f"""\ examples: Name, email, or phone. Fuzzy; default min-score is 0.68. Production must pass --base-url (the default host is the LAN copy): ./redmine_contacts.py --base-url https://rm.ldrprep.com search "Sahar Daod" Exact tag. The positional query is still required; repeat the tag so the tag text itself matches. --refresh reloads the cache first: ./redmine_contacts.py --base-url https://rm.ldrprep.com \\ search promotional-spam --with-tag promotional-spam --refresh Same tag lookup with no API call. The on-disk cache can be stale: ./redmine_contacts.py --base-url https://rm.ldrprep.com \\ search promotional-spam --with-tag promotional-spam --offline-cache This searches contacts, not tickets. Ticket search cannot see contact tags. After you have an id: bin/rtl search --status New --open-only --where 'helpdesk_ticket.contact_id == 4589' More examples: {SEARCH_EXAMPLES_DOC} """ SEARCH_FIELDS = ( "first_name", "middle_name", "last_name", "company", "job_title", "background", "website", "skype_name", "tag_list", ) UPDATE_FIELDS = { "first_name", "middle_name", "last_name", "company", "website", "skype_name", "birthday", "job_title", "background", "phone", "email", "tag_list", } BATCH_ORDER_CHOICES = ( "as-is", "oldest-updated-first", "newest-updated-first", "oldest-created-first", "newest-created-first", "id-asc", "id-desc", ) class RedmineError(RuntimeError): pass @dataclass class RedmineClient: base_url: str api_key: str project: str def request( self, method: str, path: str, params: dict[str, Any] | None = None, payload: dict[str, Any] | None = None, impersonate_user: str | None = None, ) -> dict[str, Any]: url = self._url(path, params) body = None headers = { "Accept": "application/json", "X-Redmine-API-Key": self.api_key, } if impersonate_user: headers["X-Redmine-Switch-User"] = impersonate_user if payload is not None: body = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read() except urllib.error.HTTPError as exc: detail = (exc.read() or b'').decode("utf-8", errors="replace") raise RedmineError(f"HTTP {exc.code} from Redmine: {detail[:500]}") from exc except urllib.error.URLError as exc: raise RedmineError(f"Could not reach Redmine: {exc.reason}") from exc if not raw: return {} try: return json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: raise RedmineError(f"Redmine returned non-JSON response from {url}") from exc def fetch_contacts( self, limit: int = 100, extra_params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: contacts: list[dict[str, Any]] = [] offset = 0 total = None while total is None or offset < total: params = {"limit": limit, "offset": offset} if extra_params: params.update(extra_params) data = self.request( "GET", f"/projects/{self.project}/contacts.json", params, ) page = data.get("contacts", []) contacts.extend(page) total = int(data.get("total_count", len(contacts))) received = len(page) if received == 0: break offset += received return contacts def get_contact(self, contact_id: int) -> dict[str, Any]: data = self.request("GET", f"/contacts/{contact_id}.json") contact = data.get("contact") if isinstance(contact, dict): return contact if isinstance(data, dict) and data.get("id") == contact_id: return data raise RedmineError(f"Redmine contact payload for id={contact_id} was missing 'contact'.") def get_issue(self, issue_id: int, includes: Iterable[str] | None = None) -> dict[str, Any]: params: dict[str, Any] = {} if includes: params["include"] = ",".join(includes) data = self.request("GET", f"/issues/{issue_id}.json", params=params or None) issue = data.get("issue") if isinstance(issue, dict): return issue if isinstance(data, dict) and data.get("id") == issue_id: return data raise RedmineError(f"Redmine issue payload for id={issue_id} was missing 'issue'.") def issue_with_helpdesk(self, issue_id: int, message_limit: int = 100) -> dict[str, Any]: issue = self.get_issue(issue_id, includes=("journals", "attachments", "children", "relations", "changesets")) response: dict[str, Any] = {"issue": issue} try: response["helpdesk_ticket"] = self.helpdesk_ticket_by_issue(issue_id) except RedmineError as exc: response["helpdesk_ticket_error"] = str(exc) try: response["helpdesk_messages"] = self.helpdesk_messages_by_issue(issue_id, message_limit) except RedmineError as exc: response["helpdesk_messages_error"] = str(exc) return response def update_contact(self, contact_id: int, fields: dict[str, Any], impersonate_user: str | None = None) -> None: self.request( "PUT", f"/contacts/{contact_id}.json", payload={"contact": fields}, impersonate_user=impersonate_user, ) def helpdesk_ticket_by_issue(self, issue_id: int) -> dict[str, Any]: return self.request("GET", f"/helpdesk_search/issues/{issue_id}/ticket") def helpdesk_issues_by_contact(self, contact_id: int, limit: int) -> dict[str, Any]: return self.request("GET", f"/helpdesk_search/contacts/{contact_id}/issues", {"limit": limit}) def helpdesk_messages_by_issue(self, issue_id: int, limit: int) -> dict[str, Any]: return self.request("GET", f"/helpdesk_search/issues/{issue_id}/messages", {"limit": limit}) def helpdesk_timeline(self, contact_id: int, limit: int) -> dict[str, Any]: return self.request("GET", f"/helpdesk_search/contacts/{contact_id}/timeline", {"limit": limit}) def _url(self, path: str, params: dict[str, Any] | None = None) -> str: base = self.base_url.rstrip("/") url = f"{base}{path}" if params: url += "?" + urllib.parse.urlencode(params) return url class ContactsHelpFormatter( argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter, ): """Show defaults, and keep example blocks in description/epilog.""" def main() -> int: parser = argparse.ArgumentParser( description="Search and update Redmine CRM contacts with safe dry-run-first workflows.", epilog=( "Contact search examples: ./redmine_contacts.py search --help\n" f"Cookbook: {SEARCH_EXAMPLES_DOC}" ), formatter_class=ContactsHelpFormatter, ) parser.add_argument( "--base-url", default=os.getenv("REDMINE_BASE_URL", DEFAULT_BASE_URL), help="Redmine base URL.", ) parser.add_argument( "--project", default=os.getenv("REDMINE_PROJECT", DEFAULT_PROJECT), help="Redmine project identifier for contacts list calls.", ) parser.add_argument("--cache", type=Path, help="Override the cache file path.") subparsers = parser.add_subparsers(dest="command", required=True) fetch_parser = subparsers.add_parser( "fetch", help="Fetch contacts and cache them locally.", description="Fetch contacts from Redmine, optionally filter for reporting, and cache results.", ) fetch_parser.add_argument( "--limit", type=int, default=100, help="Page size for Redmine pagination requests.", ) fetch_parser.add_argument( "--cache-mode", choices=("auto", "full", "filtered"), default="auto", help=( "Cache mode: auto uses filtered when any contact filter is provided, otherwise full; " "full always caches all fetched contacts; filtered caches only matches." ), ) add_contact_filter_args(fetch_parser) search_parser = subparsers.add_parser( "search", help="Search contacts from cache.", description=( "Fuzzy-search cached CRM contacts by name, email, phone, company, or tag text.\n" "This does not search tickets. Exact tags use --with-tag, not the query alone.\n" f"Short examples are below. Full set: {SEARCH_EXAMPLES_DOC}" ), epilog=SEARCH_EXAMPLES, formatter_class=ContactsHelpFormatter, ) search_parser.add_argument( "query", help=( "Required fuzzy text (name, email, phone, company, or tag text). " "Not an exact tag filter; pair with --with-tag for that." ), ) search_parser.add_argument( "--refresh", action="store_true", help="Reload the contact cache from Redmine before searching. Without this, tags added since the last fetch are invisible.", ) search_parser.add_argument( "--offline-cache", action="store_true", help="Search only the local cache without requiring REDMINE_API_KEY.", ) search_parser.add_argument("--limit", type=int, default=20, help="Maximum number of matches to print.") search_parser.add_argument( "--min-score", type=float, default=0.68, help="Minimum fuzzy match score threshold (0.0-1.0).", ) add_contact_filter_args(search_parser) show_parser = subparsers.add_parser( "show", help="Show one contact JSON object by id.", description="Print a contact JSON object from cache/API with optional cache refresh.", ) show_parser.add_argument("contact_id", type=int, help="Contact id.") show_parser.add_argument( "--source", choices=("auto", "cache", "api"), default="auto", help="Read from cache, API, or auto-fallback to API when cache miss.", ) show_parser.add_argument("--refresh-cache", action="store_true", help="Refresh cache before reading.") update_parser = subparsers.add_parser( "update", help="Update safe fields on one contact.", description="Build and optionally apply one-contact update payloads (dry run by default).", ) update_parser.add_argument("contact_id", type=int, help="Contact id.") update_parser.add_argument( "--set", action="append", default=[], metavar="FIELD=VALUE", help="Set a contact field (allowed keys are restricted). Repeat for multiple fields.", ) update_parser.add_argument("--add-tag", action="append", default=[], metavar="TAG", help="Tag to add.") update_parser.add_argument( "--remove-tag", action="append", default=[], metavar="TAG", help="Tag to remove.", ) update_parser.add_argument( "--set-custom-field", action="append", default=[], metavar="NAME=VALUE", help="Set one custom field by kebab-case name, preserving other custom fields.", ) update_parser.add_argument( "--contact-source", choices=("api", "cache", "auto"), default="api", help="Source used to read existing tags/custom-fields before building update payload.", ) update_parser.add_argument("--apply", action="store_true", help="Actually send the update.") batch_update_parser = subparsers.add_parser( "batch-update", help="Apply one payload to a filtered contact batch.", description=( "Select contacts by filters, print one payload per contact, and only write when --apply is passed. " "At least one filter is required as a safety guard." ), ) batch_update_parser.add_argument( "--max-contacts", type=int, default=20, help="Maximum number of selected contacts to process.", ) batch_update_parser.add_argument( "--order", choices=BATCH_ORDER_CHOICES, default="as-is", help=( "Deterministic selection order. Use oldest-updated-first/newest-updated-first for campaign sweeps." ), ) batch_update_parser.add_argument( "--source", choices=("cache", "api"), default="cache", help="Read candidate contacts from cache or live API.", ) batch_update_parser.add_argument( "--refresh-cache", action="store_true", help="Refresh the cache from API before selecting contacts.", ) add_contact_filter_args(batch_update_parser) batch_update_parser.add_argument( "--set", action="append", default=[], metavar="FIELD=VALUE", help="Set a contact field (allowed keys are restricted). Repeat for multiple fields.", ) batch_update_parser.add_argument("--add-tag", action="append", default=[], metavar="TAG", help="Tag to add.") batch_update_parser.add_argument( "--remove-tag", action="append", default=[], metavar="TAG", help="Tag to remove.", ) batch_update_parser.add_argument( "--set-custom-field", action="append", default=[], metavar="NAME=VALUE", help="Set one custom field by kebab-case name, preserving other custom fields.", ) batch_update_parser.add_argument("--apply", action="store_true", help="Actually send updates.") loop_run_parser = subparsers.add_parser( "loop-run", help="Run an extensible per-contact FILTER-1/FILTER-2 triage loop.", description=( "Select contacts by filters, run a profile-defined FILTER-1 gate, fetch recent helpdesk issues, " "call a FILTER-2 command worker, and apply generic queue-state tag updates only with --apply. " "At least one contact filter is required as a safety guard." ), ) loop_run_parser.add_argument("--profile", type=Path, required=True, help="JSON loop profile path.") loop_run_parser.add_argument( "--max-contacts", type=int, default=10, help="Maximum number of selected contacts to process.", ) loop_run_parser.add_argument( "--order", choices=BATCH_ORDER_CHOICES, default="oldest-updated-first", help="Deterministic selection order for loop candidates.", ) loop_run_parser.add_argument( "--source", choices=("cache", "api"), default="cache", help="Read candidate contacts from cache or live API.", ) loop_run_parser.add_argument( "--refresh-cache", action="store_true", help="Refresh the contact cache from API before selecting contacts.", ) add_contact_filter_args(loop_run_parser) loop_run_parser.add_argument("--apply", action="store_true", help="Actually apply runner-owned tag updates.") ticket_parser = subparsers.add_parser( "helpdesk-ticket", help="Fetch helpdesk ticket metadata for an issue.", description="Fetch helpdesk ticket metadata for one issue id.", ) ticket_parser.add_argument("issue_id", type=int, help="Issue id.") issue_full_parser = subparsers.add_parser( "issue-full", help="Fetch a full issue object with journals/relations plus helpdesk metadata.", description="Fetch full issue data and merge in helpdesk ticket + message context.", ) issue_full_parser.add_argument("issue_id", type=int, help="Issue id.") issue_full_parser.add_argument( "--message-limit", type=int, default=100, help="Maximum helpdesk journal messages to include.", ) contact_issues_parser = subparsers.add_parser( "helpdesk-issues", help="List helpdesk issues for a contact.", description="List helpdesk issue records for one contact id.", ) contact_issues_parser.add_argument("contact_id", type=int, help="Contact id.") contact_issues_parser.add_argument("--limit", type=int, default=100, help="Maximum records to return.") issue_messages_parser = subparsers.add_parser( "helpdesk-messages", help="List helpdesk journal message metadata for an issue.", description="List helpdesk journal-message metadata for one issue id.", ) issue_messages_parser.add_argument("issue_id", type=int, help="Issue id.") issue_messages_parser.add_argument("--limit", type=int, default=100, help="Maximum records to return.") timeline_parser = subparsers.add_parser( "helpdesk-timeline", help="Show a contact's helpdesk ticket/message timeline.", description="Return timeline-style helpdesk ticket/message metadata for one contact id.", ) timeline_parser.add_argument("contact_id", type=int, help="Contact id.") timeline_parser.add_argument("--limit", type=int, default=100, help="Maximum records to return.") args = parser.parse_args() try: if args.command == "fetch": client = client_from_args(args) contacts = client.fetch_contacts(limit=args.limit) filtered_contacts = filter_contacts_from_args(contacts, args) cache_path = resolve_cache_path(args) effective_cache_mode = resolve_fetch_cache_mode(args) cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts write_cache(cache_path, args, cache_contacts) if effective_cache_mode == "filtered": print( f"Cached {len(filtered_contacts)} filtered contacts " f"(from {len(contacts)} fetched) in {cache_path}" ) elif has_contact_filters(args): print( f"Cached {len(contacts)} contacts in {cache_path}. " f"{len(filtered_contacts)} contacts currently match the supplied filters." ) else: print(f"Cached {len(contacts)} contacts in {cache_path}") return 0 if args.command == "search": contacts = load_or_refresh(args) contacts = filter_contacts_from_args(contacts, args) matches = search_contacts(contacts, args.query, args.min_score) print_matches(matches[: args.limit]) return 0 if args.command == "show": contact = resolve_contact_from_args(args) print_json(contact) return 0 if args.command == "update": client = client_from_args(args) fields = build_update_fields(args, client) if not fields: raise RedmineError( "No updates supplied. Use --set, --add-tag, --remove-tag, or --set-custom-field." ) print(f"Contact {args.contact_id} update payload:") print(json.dumps({"contact": fields}, indent=2, sort_keys=True)) if not args.apply: print("Dry run only. Re-run with --apply to write to Redmine.") return 0 client.update_contact(args.contact_id, fields) print("Update sent.") return 0 if args.command == "batch-update": if args.max_contacts <= 0: raise RedmineError("--max-contacts must be >= 1.") if not has_contact_filters(args): raise RedmineError( "batch-update requires at least one filter (date/tag/no-tags) to avoid sweeping all contacts." ) if not has_update_changes(args): raise RedmineError( "No updates supplied. Use --set, --add-tag, --remove-tag, or --set-custom-field." ) contacts = load_contacts_for_batch(args) selected = select_batch_contacts(contacts, args) if not selected: print("No contacts matched filters.") return 0 client = client_from_args(args) print(f"Selected {len(selected)} contact(s) for batch update.") updated_count = 0 for contact in selected: contact_raw_id = contact.get("id") if contact_raw_id is None: raise RedmineError("Encountered contact without id in selected batch.") contact_id = int(contact_raw_id) fields = build_update_fields_for_contact(contact, args) print(f"Contact {contact_id} update payload:") print(json.dumps({"contact": fields}, indent=2, sort_keys=True)) if args.apply: client.update_contact(contact_id, fields) updated_count += 1 if args.apply: print(f"Applied updates to {updated_count} contact(s).") else: print("Dry run only. Re-run with --apply to write updates.") return 0 if args.command == "loop-run": outcomes = run_loop_from_args(args) if not outcomes: print("No contacts matched filters.") return 0 print(f"Processed {len(outcomes)} contact(s) with loop profile {args.profile}.") for outcome in outcomes: print( f"Contact {outcome['contact_id']}: {outcome['action']} " f"applied={outcome['applied']} reason={outcome['reason']}" ) print(json.dumps({"contact": outcome["fields"]}, indent=2, sort_keys=True)) if not args.apply: print("Dry run only. Re-run with --apply to write runner-owned tag updates.") return 0 if args.command == "helpdesk-ticket": client = client_from_args(args) print_json(client.helpdesk_ticket_by_issue(args.issue_id)) return 0 if args.command == "issue-full": client = client_from_args(args) print_json(client.issue_with_helpdesk(args.issue_id, message_limit=args.message_limit)) return 0 if args.command == "helpdesk-issues": client = client_from_args(args) print_json(client.helpdesk_issues_by_contact(args.contact_id, args.limit)) return 0 if args.command == "helpdesk-messages": client = client_from_args(args) print_json(client.helpdesk_messages_by_issue(args.issue_id, args.limit)) return 0 if args.command == "helpdesk-timeline": client = client_from_args(args) print_json(client.helpdesk_timeline(args.contact_id, args.limit)) return 0 except RedmineError as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 1 def client_from_args(args: argparse.Namespace) -> RedmineClient: api_key = os.getenv("REDMINE_API_KEY") if not api_key: raise RedmineError("Set REDMINE_API_KEY in the environment.") return RedmineClient(base_url=args.base_url, api_key=api_key, project=args.project) def add_contact_filter_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--created-after", metavar="ISO_DATE", help="Keep contacts created on/after this date or datetime (ISO format).", ) parser.add_argument( "--created-before", metavar="ISO_DATE", help="Keep contacts created on/before this date or datetime (ISO format).", ) parser.add_argument( "--updated-after", metavar="ISO_DATE", help="Keep contacts updated on/after this date or datetime (ISO format).", ) parser.add_argument( "--updated-before", metavar="ISO_DATE", help="Keep contacts updated on/before this date or datetime (ISO format).", ) parser.add_argument( "--with-tag", action="append", default=[], metavar="TAG", help="Require this exact tag (case-insensitive). Repeat to require every tag.", ) parser.add_argument( "--without-tag", action="append", default=[], metavar="TAG", help="Exclude contacts containing tag(s). Repeat for multiple tags.", ) parser.add_argument( "--no-tags", action="store_true", help="Only include contacts that have no tags.", ) def load_or_refresh(args: argparse.Namespace) -> list[dict[str, Any]]: cache_path = resolve_cache_path(args) if args.refresh or not cache_path.exists(): client = client_from_args(args) contacts = client.fetch_contacts() write_cache(cache_path, args, contacts) return contacts if not args.offline_cache: require_api_key_for_cached_search() data = read_cache_payload(cache_path, args) return data["contacts"] def filter_contacts_from_args(contacts: list[dict[str, Any]], args: argparse.Namespace) -> list[dict[str, Any]]: return filter_contacts( contacts, created_after=getattr(args, "created_after", None), created_before=getattr(args, "created_before", None), updated_after=getattr(args, "updated_after", None), updated_before=getattr(args, "updated_before", None), with_tags=getattr(args, "with_tag", []) or [], without_tags=getattr(args, "without_tag", []) or [], no_tags=bool(getattr(args, "no_tags", False)), ) def has_contact_filters(args: argparse.Namespace) -> bool: return any( ( bool(getattr(args, "created_after", None)), bool(getattr(args, "created_before", None)), bool(getattr(args, "updated_after", None)), bool(getattr(args, "updated_before", None)), bool(getattr(args, "with_tag", []) or []), bool(getattr(args, "without_tag", []) or []), bool(getattr(args, "no_tags", False)), ) ) def resolve_fetch_cache_mode(args: argparse.Namespace) -> str: mode = getattr(args, "cache_mode", "auto") if mode in {"full", "filtered"}: return mode if mode != "auto": raise RedmineError(f"Unsupported --cache-mode value: {mode!r}") return "filtered" if has_contact_filters(args) else "full" def load_loop_profile(profile_path: Path) -> dict[str, Any]: with profile_path.open("r", encoding="utf-8") as handle: profile = json.load(handle) if not isinstance(profile, dict): raise RedmineError(f"Loop profile {profile_path} must contain a JSON object.") if not profile.get("default_tag"): raise RedmineError(f"Loop profile {profile_path} is missing required default_tag.") if not isinstance(profile.get("filter_1"), dict): raise RedmineError(f"Loop profile {profile_path} is missing required filter_1 object.") if not isinstance(profile.get("filter_2"), dict): raise RedmineError(f"Loop profile {profile_path} is missing required filter_2 object.") normalized = dict(profile) normalized.setdefault("name", profile_path.stem) normalized["_profile_dir"] = str(profile_path.resolve().parent) normalized["remove_tags"] = list(normalized.get("remove_tags") or []) ticket_fetch = dict(normalized.get("ticket_fetch") or {}) ticket_fetch.setdefault("fetch_full_issues", False) ticket_fetch.setdefault("message_limit", 25) ticket_fetch.setdefault("sanitize_text", True) normalized["ticket_fetch"] = ticket_fetch return normalized def run_loop_from_args(args: argparse.Namespace) -> list[dict[str, Any]]: if args.max_contacts <= 0: raise RedmineError("--max-contacts must be >= 1.") if not has_contact_filters(args): raise RedmineError("loop-run requires at least one filter (date/tag/no-tags) to avoid sweeping all contacts.") profile = load_loop_profile(args.profile) contacts = load_contacts_for_batch(args) selected = select_batch_contacts(contacts, args) if not selected: return [] client = client_from_args(args) runtime = { "base_url": args.base_url, "project": args.project, "dry_run": not args.apply, "helper_command": str(Path(__file__).resolve()), } return [run_loop_for_contact(client, profile, contact, runtime=runtime) for contact in selected] def run_loop_filter1(profile: dict[str, Any], contact: dict[str, Any]) -> dict[str, Any]: filter_config = profile.get("filter_1") or {} if filter_config.get("type") == "builtin" and filter_config.get("name") == "fixed-ticket-limit": limit = int(filter_config.get("limit", profile.get("ticket_fetch", {}).get("default_limit", 5))) if limit <= 0: return {"result": False, "reason": "fixed-ticket-limit was <= 0"} return {"result": limit, "reason": "builtin fixed-ticket-limit"} raise RedmineError(f"Unsupported FILTER-1 config: {filter_config!r}") def run_loop_filter2( profile: dict[str, Any], bundle: dict[str, Any], *, runtime: dict[str, Any], ) -> dict[str, Any]: filter_config = profile.get("filter_2") or {} if filter_config.get("type") != "command": raise RedmineError(f"Unsupported FILTER-2 config: {filter_config!r}") argv = filter_config.get("argv") or [] if not isinstance(argv, list) or not all(isinstance(item, str) and item for item in argv): raise RedmineError("FILTER-2 command requires a non-empty argv string list.") env = os.environ.copy() env["REDMINE_BASE_URL"] = str(runtime.get("base_url") or DEFAULT_BASE_URL) env["REDMINE_PROJECT"] = str(runtime.get("project") or DEFAULT_PROJECT) env["REDMINE_CONTACTS_HELPER"] = str(runtime.get("helper_command") or Path(__file__).resolve()) env["REDMINE_LOOP_DRY_RUN"] = "1" if runtime.get("dry_run", True) else "0" timeout = int(filter_config.get("timeout_seconds", 30)) try: completed = subprocess.run( argv, input=json.dumps(bundle), text=True, capture_output=True, timeout=timeout, check=False, env=env, cwd=str(filter_config.get("cwd") or profile.get("_profile_dir") or Path.cwd()), ) except subprocess.TimeoutExpired as exc: raise RedmineError(f"FILTER-2 command timed out after {timeout}s: {argv!r}") from exc except OSError as exc: raise RedmineError(f"FILTER-2 command failed to start: {argv!r}: {exc}") from exc if completed.returncode != 0: stderr = completed.stderr.strip() raise RedmineError(f"FILTER-2 command exited {completed.returncode}: {stderr[:500]}") stdout = completed.stdout.strip() if not stdout: raise RedmineError("FILTER-2 command produced no JSON output.") try: result = json.loads(stdout) except json.JSONDecodeError as exc: raise RedmineError(f"FILTER-2 command produced non-JSON output: {stdout[:500]}") from exc if not isinstance(result, dict) or "result" not in result: raise RedmineError("FILTER-2 command output must be a JSON object containing result.") return result def run_loop_for_contact( client: RedmineClient, profile: dict[str, Any], contact: dict[str, Any], *, runtime: dict[str, Any], ) -> dict[str, Any]: contact_id = contact.get("id") if contact_id is None: raise RedmineError("Loop contact is missing id.") contact_id = int(contact_id) filter1_result = run_loop_filter1(profile, contact) if filter1_result.get("result") is False: return apply_loop_contact_action( client, profile, contact, runtime=runtime, action="default-tag", reason=str(filter1_result.get("reason") or "FILTER-1 returned false"), ) raw_ticket_limit = filter1_result.get("result") if raw_ticket_limit is None: raise RedmineError("FILTER-1 result must be false or an integer ticket limit.") ticket_limit = int(raw_ticket_limit) issues = fetch_loop_issues(client, profile, contact_id, ticket_limit) bundle = build_loop_filter2_bundle(profile, contact, issues, runtime) filter2_result = run_loop_filter2(profile, bundle, runtime=runtime) if filter2_result.get("result"): return apply_loop_contact_action( client, profile, contact, runtime=runtime, action="remove-tags", reason=str(filter2_result.get("summary") or "FILTER-2 returned true"), ) return apply_loop_contact_action( client, profile, contact, runtime=runtime, action="default-tag", reason=str(filter2_result.get("summary") or "FILTER-2 returned false"), ) def fetch_loop_issues( client: RedmineClient, profile: dict[str, Any], contact_id: int, ticket_limit: int, ) -> list[dict[str, Any]]: ticket_fetch = profile.get("ticket_fetch") or {} raw = client.helpdesk_issues_by_contact(contact_id, ticket_limit) summaries = extract_helpdesk_issue_list(raw) issues: list[dict[str, Any]] = [] for summary in summaries[:ticket_limit]: item = {"issue_summary": summary, "full_issue": None} if ticket_fetch.get("fetch_full_issues"): issue_id = summary.get("issue_id") or summary.get("id") if issue_id is not None: item["full_issue"] = client.issue_with_helpdesk( int(issue_id), message_limit=int(ticket_fetch.get("message_limit", 25)), ) issues.append(item) return issues def extract_helpdesk_issue_list(raw: Any) -> list[dict[str, Any]]: if isinstance(raw, list): return [item for item in raw if isinstance(item, dict)] if not isinstance(raw, dict): return [] for key in ("issues", "helpdesk_issues", "tickets", "records"): value = raw.get(key) if isinstance(value, list): return [item for item in value if isinstance(item, dict)] return [] def build_loop_filter2_bundle( profile: dict[str, Any], contact: dict[str, Any], issues: list[dict[str, Any]], runtime: dict[str, Any], ) -> dict[str, Any]: return { "profile": { "name": profile.get("name"), "default_tag": profile.get("default_tag"), "remove_tags": profile.get("remove_tags") or [], }, "contact": contact, "issues": issues, "runtime": { "base_url": runtime.get("base_url") or DEFAULT_BASE_URL, "project": runtime.get("project") or DEFAULT_PROJECT, "dry_run": bool(runtime.get("dry_run", True)), "helper_command": str(runtime.get("helper_command") or Path(__file__).resolve()), }, } def apply_loop_contact_action( client: RedmineClient, profile: dict[str, Any], contact: dict[str, Any], *, runtime: dict[str, Any], action: str, reason: str, ) -> dict[str, Any]: if action == "default-tag": fields = {"tag_list": merge_tags(extract_tag_list(contact), [str(profile["default_tag"])], [])} elif action == "remove-tags": fields = {"tag_list": merge_tags(extract_tag_list(contact), [], list(profile.get("remove_tags") or []))} else: raise RedmineError(f"Unsupported loop action: {action}") outcome = { "contact_id": int(contact["id"]), "action": action, "reason": reason, "fields": fields, "applied": False, } if not runtime.get("dry_run", True): client.update_contact(int(contact["id"]), fields) outcome["applied"] = True return outcome def read_cache_payload(cache_path: Path, args: argparse.Namespace) -> dict[str, Any]: with cache_path.open("r", encoding="utf-8") as handle: data = json.load(handle) validate_cache_metadata(data, args, cache_path) return data def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]: source = args.source cache_path = resolve_cache_path(args) if args.refresh_cache: client = client_from_args(args) contacts = client.fetch_contacts() write_cache(cache_path, args, contacts) if source in {"cache", "auto"}: if cache_path.exists(): data = read_cache_payload(cache_path, args) cached_contact = find_contact_in_list(data.get("contacts") or [], args.contact_id) if cached_contact is not None: return cached_contact if source == "cache": raise RedmineError(f"Contact {args.contact_id} not found in cache {cache_path}.") client = client_from_args(args) return client.get_contact(args.contact_id) def find_contact_in_list(contacts: list[dict[str, Any]], contact_id: int) -> dict[str, Any] | None: for contact in contacts: if int(contact.get("id", -1)) == int(contact_id): return contact return None def has_update_changes(args: argparse.Namespace) -> bool: return any( ( bool(getattr(args, "set", []) or []), bool(getattr(args, "add_tag", []) or []), bool(getattr(args, "remove_tag", []) or []), bool(getattr(args, "set_custom_field", []) or []), ) ) def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]: cache_path = resolve_cache_path(args) if args.source == "api": client = client_from_args(args) contacts = client.fetch_contacts() if args.refresh_cache: write_cache(cache_path, args, contacts) return contacts if args.refresh_cache or not cache_path.exists(): client = client_from_args(args) contacts = client.fetch_contacts() write_cache(cache_path, args, contacts) return contacts data = read_cache_payload(cache_path, args) return data["contacts"] def select_batch_contacts(contacts: list[dict[str, Any]], args: argparse.Namespace) -> list[dict[str, Any]]: filtered = filter_contacts_from_args(contacts, args) ordered = order_contacts_for_batch(filtered, getattr(args, "order", "as-is")) return ordered[: args.max_contacts] def order_contacts_for_batch(contacts: list[dict[str, Any]], order: str) -> list[dict[str, Any]]: if order == "as-is": return list(contacts) if order == "oldest-updated-first": return sorted(contacts, key=lambda c: sort_key_by_datetime(c, "updated_on", ascending=True)) if order == "newest-updated-first": return sorted(contacts, key=lambda c: sort_key_by_datetime(c, "updated_on", ascending=False)) if order == "oldest-created-first": return sorted(contacts, key=lambda c: sort_key_by_datetime(c, "created_on", ascending=True)) if order == "newest-created-first": return sorted(contacts, key=lambda c: sort_key_by_datetime(c, "created_on", ascending=False)) if order == "id-asc": return sorted(contacts, key=sort_key_by_id_asc) if order == "id-desc": return sorted(contacts, key=sort_key_by_id_desc) raise RedmineError(f"Unsupported batch order: {order!r}") def sort_key_by_datetime(contact: dict[str, Any], field: str, *, ascending: bool) -> tuple[int, float, int]: value = parse_date_or_datetime(contact.get(field)) contact_id = contact_numeric_id(contact) if value is None: return (1, 0.0, contact_id) timestamp = value.timestamp() if not ascending: timestamp = -timestamp return (0, timestamp, contact_id) def sort_key_by_id_asc(contact: dict[str, Any]) -> int: return contact_numeric_id(contact) def sort_key_by_id_desc(contact: dict[str, Any]) -> int: return -contact_numeric_id(contact) def contact_numeric_id(contact: dict[str, Any]) -> int: raw_id = contact.get("id") if raw_id is None: return sys.maxsize try: return int(raw_id) except (TypeError, ValueError): return sys.maxsize def build_update_fields(args: argparse.Namespace, client: RedmineClient) -> dict[str, Any]: needs_contact = bool(args.add_tag or args.remove_tag or args.set_custom_field) if not needs_contact: return parse_update_fields(args.set) contact = resolve_contact_for_update(args, client) return build_update_fields_for_contact(contact, args) def build_update_fields_for_contact(contact: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: fields: dict[str, Any] = parse_update_fields(getattr(args, "set", []) or []) add_tags = list(getattr(args, "add_tag", []) or []) remove_tags = list(getattr(args, "remove_tag", []) or []) if add_tags or remove_tags: fields["tag_list"] = merge_tags( existing=extract_tag_list(contact), add_tags=add_tags, remove_tags=remove_tags, ) custom_assignments = list(getattr(args, "set_custom_field", []) or []) if custom_assignments: assignments = parse_custom_field_assignments(custom_assignments) fields["custom_field_values"] = build_custom_field_values_patch( contact.get("custom_fields") or [], assignments, ) return fields def resolve_contact_for_update(args: argparse.Namespace, client: RedmineClient) -> dict[str, Any]: source = args.contact_source cache_path = resolve_cache_path(args) if source in {"cache", "auto"} and cache_path.exists(): data = read_cache_payload(cache_path, args) cached_contact = find_contact_in_list(data.get("contacts") or [], args.contact_id) if cached_contact is not None: return cached_contact if source == "cache": raise RedmineError(f"Contact {args.contact_id} not found in cache {cache_path}.") return client.get_contact(args.contact_id) def parse_custom_field_assignments(assignments: list[str]) -> dict[str, str]: parsed: dict[str, str] = {} for assignment in assignments: if "=" not in assignment: raise RedmineError(f"Invalid --set-custom-field value: {assignment!r}") key, value = assignment.split("=", 1) slug = slugify_custom_field_name(key) if not slug: raise RedmineError(f"Invalid custom field key {key!r}") parsed[slug] = value return parsed def build_custom_field_values_patch( existing_custom_fields: list[dict[str, Any]], assignments_by_slug: dict[str, str], ) -> dict[str, str]: by_slug: dict[str, dict[str, Any]] = {} for field in existing_custom_fields: field_name = str(field.get("name") or "") field_id = field.get("id") if not field_name or field_id is None: continue by_slug[slugify_custom_field_name(field_name)] = field patch: dict[str, str] = {} unknown: list[str] = [] for slug, value in assignments_by_slug.items(): field = by_slug.get(slug) if not field: unknown.append(slug) continue patch[str(field["id"])] = value if unknown: available = ", ".join(sorted(key for key in by_slug if key)) raise RedmineError( "Unknown custom field slug(s): " + ", ".join(sorted(unknown)) + (f". Available: {available}" if available else "") ) return patch def slugify_custom_field_name(name: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()) return slug.strip("-") def merge_tags(existing: list[str], add_tags: list[str], remove_tags: list[str]) -> list[str]: merged = {tag.strip() for tag in existing if tag and tag.strip()} for tag in add_tags: clean = tag.strip() if clean: merged.add(clean) for tag in remove_tags: clean = tag.strip() if clean: merged.discard(clean) return sorted(merged) def extract_tag_list(contact: dict[str, Any]) -> list[str]: tags = contact.get("tag_list") if not tags: return [] if isinstance(tags, list): return [str(tag).strip() for tag in tags if str(tag).strip()] if isinstance(tags, str): return [part.strip() for part in tags.split(",") if part.strip()] return [str(tags).strip()] def filter_contacts( contacts: list[dict[str, Any]], *, created_after: str | None = None, created_before: str | None = None, updated_after: str | None = None, updated_before: str | None = None, with_tags: list[str] | None = None, without_tags: list[str] | None = None, no_tags: bool = False, ) -> list[dict[str, Any]]: if no_tags and with_tags: raise RedmineError("--no-tags cannot be combined with --with-tag.") created_after_dt = parse_date_or_datetime(created_after) if created_after else None created_before_dt = parse_date_or_datetime(created_before, end_of_day=True) if created_before else None updated_after_dt = parse_date_or_datetime(updated_after) if updated_after else None updated_before_dt = parse_date_or_datetime(updated_before, end_of_day=True) if updated_before else None with_tags_set = {tag.strip().lower() for tag in (with_tags or []) if tag.strip()} without_tags_set = {tag.strip().lower() for tag in (without_tags or []) if tag.strip()} filtered: list[dict[str, Any]] = [] for contact in contacts: created_on = parse_date_or_datetime(contact.get("created_on")) updated_on = parse_date_or_datetime(contact.get("updated_on")) tags = {tag.lower() for tag in extract_tag_list(contact)} if created_after_dt and (created_on is None or created_on < created_after_dt): continue if created_before_dt and (created_on is None or created_on > created_before_dt): continue if updated_after_dt and (updated_on is None or updated_on < updated_after_dt): continue if updated_before_dt and (updated_on is None or updated_on > updated_before_dt): continue if no_tags and tags: continue if with_tags_set and not with_tags_set.issubset(tags): continue if without_tags_set and without_tags_set.intersection(tags): continue filtered.append(contact) return filtered def parse_date_or_datetime(value: Any, end_of_day: bool = False) -> datetime | None: if value is None: return None raw = str(value).strip() if not raw: return None if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw): dt = datetime.strptime(raw, "%Y-%m-%d") if end_of_day: dt = dt.replace(hour=23, minute=59, second=59) return dt.replace(tzinfo=timezone.utc) normalized = raw.replace("Z", "+00:00") try: dt = datetime.fromisoformat(normalized) except ValueError: raise RedmineError(f"Invalid ISO date/datetime value: {raw!r}") if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def write_cache(cache_path: Path, args: argparse.Namespace, contacts: list[dict[str, Any]]) -> None: cache_path.parent.mkdir(parents=True, exist_ok=True) payload = { "base_url": canonical_base_url(args.base_url), "project": args.project, "cached_at": int(time.time()), "contacts": contacts, } with cache_path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2, sort_keys=True) handle.write("\n") def resolve_cache_path(args: argparse.Namespace) -> Path: if args.cache: return args.cache parsed = urllib.parse.urlparse(args.base_url) host = parsed.netloc or parsed.path host_slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", host.strip("/")) project_slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", args.project) return DEFAULT_CACHE_DIR / f"{host_slug}_{project_slug}.json" def canonical_base_url(base_url: str) -> str: return base_url.rstrip("/") def require_api_key_for_cached_search() -> None: if not os.getenv("REDMINE_API_KEY"): raise RedmineError( "Set REDMINE_API_KEY, or pass --offline-cache to search only a local cache explicitly." ) def validate_cache_metadata(data: dict[str, Any], args: argparse.Namespace, cache_path: Path) -> None: expected_base_url = canonical_base_url(args.base_url) cached_base_url = data.get("base_url") cached_project = data.get("project") if cached_base_url is None or cached_project is None: raise RedmineError( f"{cache_path} is a legacy cache without base-url/project metadata. " "Refresh it before searching." ) if cached_base_url != expected_base_url or cached_project != args.project: raise RedmineError( f"{cache_path} was created for {cached_base_url} project {cached_project}, " f"not {expected_base_url} project {args.project}." ) def parse_update_fields(assignments: list[str]) -> dict[str, str]: fields: dict[str, str] = {} for assignment in assignments: if "=" not in assignment: raise RedmineError(f"Invalid --set value: {assignment!r}") field, value = assignment.split("=", 1) field = field.strip() if field not in UPDATE_FIELDS: allowed = ", ".join(sorted(UPDATE_FIELDS)) raise RedmineError(f"Unsupported update field {field!r}. Allowed fields: {allowed}") fields[field] = value return fields def search_contacts( contacts: list[dict[str, Any]], query: str, min_score: float, ) -> list[tuple[float, dict[str, Any], str]]: normalized_query = normalize(query) query_digits = digits_only(query) scored: list[tuple[float, dict[str, Any], str]] = [] for contact in contacts: haystacks = contact_haystacks(contact) score, reason = score_contact(normalized_query, query_digits, haystacks) if score >= min_score: scored.append((score, contact, reason)) return sorted(scored, key=lambda item: (-item[0], display_name(item[1]).lower(), item[1].get("id", 0))) def score_contact( query: str, query_digits: str, haystacks: list[tuple[str, str]], ) -> tuple[float, str]: best_score = 0.0 best_reason = "" for label, value in haystacks: normalized_value = normalize(value) if not normalized_value: continue if query and query in normalized_value: score = 1.0 else: score = SequenceMatcher(None, query, normalized_value).ratio() for token in normalized_value.split(): score = max(score, SequenceMatcher(None, query, token).ratio() * 0.92) if score > best_score: best_score = score best_reason = label if query_digits: for label, value in haystacks: value_digits = digits_only(value) if value_digits and query_digits in value_digits: return 1.0, label if value_digits: score = SequenceMatcher(None, query_digits, value_digits).ratio() * 0.95 if score > best_score: best_score = score best_reason = label return best_score, best_reason def contact_haystacks(contact: dict[str, Any]) -> list[tuple[str, str]]: haystacks: list[tuple[str, str]] = [("name", display_name(contact))] for field in SEARCH_FIELDS: value = contact.get(field) if value: haystacks.append((field, flatten(value))) for email in contact.get("emails", []) or []: haystacks.append(("email", flatten(email))) for phone in contact.get("phones", []) or []: haystacks.append(("phone", flatten(phone))) address = contact.get("address") or {} for key, value in address.items(): if value: haystacks.append((f"address.{key}", flatten(value))) custom_fields = contact.get("custom_fields") or [] for field in custom_fields: name = field.get("name", "custom_field") value = field.get("value") if value: haystacks.append((f"custom.{name}", flatten(value))) return haystacks def display_name(contact: dict[str, Any]) -> str: if contact.get("is_company"): return str(contact.get("first_name") or contact.get("company") or "").strip() parts = [contact.get("first_name"), contact.get("middle_name"), contact.get("last_name")] name = " ".join(str(part).strip() for part in parts if part) return name or str(contact.get("company") or "").strip() def flatten(value: Any) -> str: if isinstance(value, dict): return " ".join(flatten(item) for item in value.values()) if isinstance(value, list): return " ".join(flatten(item) for item in value) return str(value) def normalize(value: str) -> str: value = value.lower() value = re.sub(r"[^a-z0-9@.+]+", " ", value) return re.sub(r"\s+", " ", value).strip() def digits_only(value: str) -> str: return re.sub(r"\D+", "", value) def print_matches(matches: list[tuple[float, dict[str, Any], str]]) -> None: if not matches: print("No contacts matched.") return for score, contact, reason in matches: emails = ", ".join(flatten(item) for item in contact.get("emails", []) or []) phones = ", ".join(flatten(item) for item in contact.get("phones", []) or []) company = contact.get("company") or "" line = f"{contact.get('id')}: {display_name(contact)}" if company and company != display_name(contact): line += f" | {company}" if emails: line += f" | {emails}" if phones: line += f" | {phones}" line += f" | score={score:.2f} via {reason}" print(line) def print_json(data: dict[str, Any]) -> None: # Helpdesk commands intentionally print raw JSON so later scripts/indexers # can pipe the metadata without parsing a human-oriented table format. print(json.dumps(data, indent=2, sort_keys=True)) def set_nmi_account_link(redmine_contact_id: int, user_account_id: str | int, client: "RedmineContactsClient | None" = None, apply: bool = False) -> dict: """Convenience: set custom field 70 (Account Number) on a Redmine contact to the NMI user_account_id.""" client = client or RedmineContactsClient() patch = build_custom_field_values_patch(["account-number=" + str(user_account_id)]) if apply: client.update_contact(redmine_contact_id, {"custom_field_values": patch.get("custom_field_values", {})}) return {"contact_id": redmine_contact_id, "set_account_number": str(user_account_id), "applied": apply} if __name__ == "__main__": raise SystemExit(main())