Organized
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
#!/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."
|
||||
)
|
||||
Reference in New Issue
Block a user