1518 lines
56 KiB
Python
Executable File
1518 lines
56 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Small CLI for searching and lightly updating Redmine CRM contacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from functools import wraps
|
|
from redmine_cache_io import CacheError, cache_lock, atomic_write_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":
|
|
with cache_lock(resolve_cache_path(args)):
|
|
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, CacheError) 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 snapshot_transaction(fn):
|
|
@wraps(fn)
|
|
def wrapped(args, *a, **kw):
|
|
path = resolve_cache_path(args)
|
|
refresh = bool(getattr(args, "refresh", False) or getattr(args, "refresh_cache", False))
|
|
if refresh or not path.exists():
|
|
# Includes fetch and replacement; serializing replacement alone lets
|
|
# a slower old fetch clobber the newer snapshot.
|
|
with cache_lock(path):
|
|
return fn(args, *a, **kw)
|
|
return fn(args, *a, **kw)
|
|
return wrapped
|
|
|
|
|
|
@snapshot_transaction
|
|
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
|
|
|
|
|
|
@snapshot_transaction
|
|
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 []),
|
|
)
|
|
)
|
|
|
|
|
|
@snapshot_transaction
|
|
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,
|
|
}
|
|
atomic_write_json(cache_path, payload)
|
|
|
|
|
|
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())
|