Files
redmine/redmine_ticket_drafts.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

227 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Local draft mutation queue for internal notes vs customer emails.
CRITICAL DISTINCTION (do not collapse these):
- internal_note → Redmine journal note only. NEVER emails the customer.
Optional private_notes. PUT /issues/:id.json
- customer_email → Helpdesk "Send note" path. ALWAYS emails the customer
(when applied). POST /helpdesk/email_note
Cannot be private.
Drafts are stored under <cache-dir>/drafts/*.json until applied or cancelled.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
KIND_INTERNAL_NOTE = "internal_note"
KIND_CUSTOMER_EMAIL = "customer_email"
KIND_FIELDS = "fields"
KIND_CONTACT_FIELDS = "contact_fields"
# Legacy aliases from earlier draft CLI
LEGACY_KIND_MAP = {
"note": KIND_INTERNAL_NOTE,
"helpdesk_reply": KIND_CUSTOMER_EMAIL,
}
# Hosts allowed for write/apply without --allow-prod-writes
DEFAULT_SAFE_WRITE_HOST_PATTERNS = (
r"^localhost$",
r"^127\.0\.0\.1$",
r"^192\.168\.\d+\.\d+$",
r"^10\.\d+\.\d+\.\d+$",
r"^retired\.int\.ldrprep\.com$",
r"^.*\.170$", # common LAN Redmine
)
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def normalize_kind(kind: str) -> str:
k = (kind or "").strip()
k = LEGACY_KIND_MAP.get(k, k)
if k not in {KIND_INTERNAL_NOTE, KIND_CUSTOMER_EMAIL, KIND_FIELDS, KIND_CONTACT_FIELDS}:
raise ValueError(
f"Unsupported draft kind {kind!r}. "
f"Use {KIND_INTERNAL_NOTE!r}, {KIND_CUSTOMER_EMAIL!r}, "
f"{KIND_FIELDS!r}, or {KIND_CONTACT_FIELDS!r}."
)
return k
def kind_label(kind: str) -> str:
kind = normalize_kind(kind)
if kind == KIND_INTERNAL_NOTE:
return "INTERNAL NOTE (does NOT email customer)"
if kind == KIND_CUSTOMER_EMAIL:
return "CUSTOMER EMAIL (WILL email customer via Helpdesk)"
if kind == KIND_FIELDS:
return "FIELD UPDATE (does NOT email customer; changes issue fields)"
if kind == KIND_CONTACT_FIELDS:
return "CONTACT UPDATE (does NOT email; changes CRM contact fields/tags)"
return kind
def drafts_dir(cache_dir: Path) -> Path:
path = Path(cache_dir) / "drafts"
path.mkdir(parents=True, exist_ok=True)
return path
def assert_kind_flags(kind: str, *, private: bool) -> str:
kind = normalize_kind(kind)
if kind == KIND_CUSTOMER_EMAIL and private:
raise ValueError(
"customer_email drafts cannot be private. "
"Private notes never email; customer emails always email. "
"Use kind=internal_note with --private for staff-only notes."
)
return kind
def add_draft(
cache_dir: Path,
*,
issue_id: int | None = None,
contact_id: int | None = None,
kind: str,
body: str = "",
private: bool = False,
fields: dict[str, Any] | None = None,
project: str | None = None,
base_url: str | None = None,
) -> Path:
kind = assert_kind_flags(kind, private=private)
body = (body or "").strip()
if kind in {KIND_INTERNAL_NOTE, KIND_CUSTOMER_EMAIL} and not body:
raise ValueError("Draft body is required for notes/emails.")
if kind == KIND_FIELDS and not fields:
raise ValueError("Field-update drafts require a non-empty fields payload.")
if kind == KIND_CONTACT_FIELDS:
if contact_id is None:
raise ValueError("contact_fields drafts require contact_id.")
if not fields:
raise ValueError("contact_fields drafts require a non-empty fields payload.")
if kind in {KIND_INTERNAL_NOTE, KIND_CUSTOMER_EMAIL, KIND_FIELDS} and issue_id is None:
raise ValueError(f"{kind} drafts require issue_id.")
draft_id = uuid.uuid4().hex[:12]
payload = {
"id": draft_id,
"created_at": now_iso(),
"status": "pending",
"project": project,
"base_url": base_url,
"issue_id": int(issue_id) if issue_id is not None else None,
"contact_id": int(contact_id) if contact_id is not None else None,
"kind": kind,
"kind_label": kind_label(kind),
"body": body,
"private": bool(private) if kind == KIND_INTERNAL_NOTE else False,
"fields": fields or {},
"applied_at": None,
"error": None,
"emails_customer": kind == KIND_CUSTOMER_EMAIL,
}
path = drafts_dir(cache_dir) / f"{draft_id}.json"
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
def load_draft(cache_dir: Path, draft_id: str) -> tuple[Path, dict[str, Any]]:
path = drafts_dir(cache_dir) / f"{draft_id}.json"
if not path.is_file():
matches = list(drafts_dir(cache_dir).glob(f"{draft_id}*.json"))
if len(matches) != 1:
raise FileNotFoundError(f"Draft not found or ambiguous: {draft_id}")
path = matches[0]
data = json.loads(path.read_text(encoding="utf-8"))
data["kind"] = normalize_kind(str(data.get("kind") or ""))
return path, data
def list_drafts(cache_dir: Path, status: str | None = "pending") -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
root = drafts_dir(cache_dir)
for path in sorted(root.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if status and data.get("status") != status:
continue
try:
data["kind"] = normalize_kind(str(data.get("kind") or ""))
except ValueError:
pass
data["kind_label"] = kind_label(str(data.get("kind") or "internal_note")) if data.get("kind") else ""
data["_path"] = str(path)
items.append(data)
return items
def cancel_draft(cache_dir: Path, draft_id: str) -> bool:
try:
path, data = load_draft(cache_dir, draft_id)
except FileNotFoundError:
return False
data["status"] = "cancelled"
data["cancelled_at"] = now_iso()
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return True
def cancel_all_pending(cache_dir: Path) -> int:
count = 0
for draft in list_drafts(cache_dir, status="pending"):
if cancel_draft(cache_dir, str(draft["id"])):
count += 1
return count
def mark_draft_applied(cache_dir: Path, draft_id: str, *, result: dict[str, Any] | None = None) -> None:
path, data = load_draft(cache_dir, draft_id)
data["status"] = "applied"
data["applied_at"] = now_iso()
data["error"] = None
data["apply_result"] = result or {}
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def mark_draft_error(cache_dir: Path, draft_id: str, error: str) -> None:
path, data = load_draft(cache_dir, draft_id)
data["status"] = "error"
data["error"] = error
data["error_at"] = now_iso()
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def host_is_safe_for_writes(base_url: str, patterns: tuple[str, ...] = DEFAULT_SAFE_WRITE_HOST_PATTERNS) -> bool:
host = (urlparse(base_url).hostname or "").lower()
if not host:
return False
return any(re.search(pat, host) for pat in patterns)
def assert_write_target_allowed(base_url: str, *, allow_prod_writes: bool) -> None:
if allow_prod_writes:
return
if host_is_safe_for_writes(base_url):
return
raise RuntimeError(
f"Refusing writes to {base_url!r}. "
"Default allow-list is LAN/test hosts only. "
"Re-run against the test Redmine, or pass --allow-prod-writes if you truly intend production."
)