Compare commits

...

6 Commits

Author SHA1 Message Date
Jason Thistlethwaite 57314dea52 Cleaned up the mess 2026-10-18 10:46:24 -04:00
Jason Thistlethwaite 7ad3723525 Unclear reason for the backup 2026-10-18 10:45:14 -04:00
Jason Thistlethwaite 4e10a21142 IDE junk and swap files 2026-10-18 10:41:17 -04:00
Jason Thistlethwaite 71bb541d0b Improved UX 2026-10-18 10:33:01 -04:00
Jason Thistlethwaite 2e686215ed Harden portable Redmine CLI runtime and cache writes 2026-10-18 03:18:36 -04:00
Jason Thistlethwaite f6915face2 docs: define portable CLI runtime and cache safety contract 2026-10-18 02:15:58 -04:00
33 changed files with 7613 additions and 270 deletions
+14
View File
@@ -1,4 +1,9 @@
/.cache/ /.cache/
/cache/*
/cache-dev/*
./run-stats.json
manifest.json
/.venv/ /.venv/
/__pycache__/ /__pycache__/
/redmine-copy/ /redmine-copy/
@@ -10,3 +15,12 @@ redMCP/composer.phar
.env .env
semantic_index/.env semantic_index/.env
*.pyc *.pyc
# IDE droppings and similar
.superpowers/
.agentbridge/
.idea/
.obsidian/
# nano swap files
*.swp
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Attachment helpers for the power-user Redmine ticket client.
List (cache-first), download to local dir, upload via Redmine REST:
POST /uploads.json?filename=… (raw body)
PUT /issues/:id.json with issue.uploads token
"""
from __future__ import annotations
import json
import mimetypes
import re
from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
def safe_filename(name: str) -> str:
name = (name or "attachment.bin").strip().replace("\\", "_").replace("/", "_")
name = re.sub(r"[^\w.\- ()\[\]+@]+", "_", name)
return name or "attachment.bin"
def attachments_from_issue(issue: dict[str, Any]) -> list[dict[str, Any]]:
atts = issue.get("attachments")
if isinstance(atts, list):
return [a for a in atts if isinstance(a, dict)]
return []
def attachment_dir(cache_dir: Path, project: str, issue_id: int) -> Path:
path = Path(cache_dir) / project / "attachments" / str(int(issue_id))
path.mkdir(parents=True, exist_ok=True)
return path
def pick_attachment(attachments: list[dict[str, Any]], attachment_id: int | None) -> dict[str, Any]:
if not attachments:
raise LookupError("Issue has no attachments.")
if attachment_id is None:
if len(attachments) == 1:
return attachments[0]
raise LookupError(
"Multiple attachments; pass --attachment-id. ids="
+ ",".join(str(a.get("id")) for a in attachments)
)
for att in attachments:
if int(att.get("id") or 0) == int(attachment_id):
return att
raise LookupError(f"Attachment id {attachment_id} not found on issue.")
def download_url_for_attachment(att: dict[str, Any], base_url: str) -> str:
urls = download_url_candidates(att, base_url)
return urls[0]
def download_url_candidates(att: dict[str, Any], base_url: str) -> list[str]:
"""Ordered download URL candidates (host rewrite + path variants)."""
base = base_url.rstrip("/")
aid = att.get("id")
filename = str(att.get("filename") or "").strip()
candidates: list[str] = []
def add(url: str) -> None:
if url and url not in candidates:
candidates.append(url)
for key in ("content_url", "contentUrl"):
raw = att.get(key)
if not raw:
continue
raw = str(raw)
add(raw)
# rewrite host to active base_url (prod content_url on LAN client, etc.)
try:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(raw)
base_p = urlparse(base if "://" in base else f"http://{base}")
rewritten = urlunparse(
(
base_p.scheme or parsed.scheme,
base_p.netloc or parsed.netloc,
parsed.path,
"",
parsed.query,
"",
)
)
add(rewritten)
except Exception:
pass
if aid is not None:
add(f"{base}/attachments/download/{int(aid)}")
if filename:
from urllib.parse import quote
add(f"{base}/attachments/download/{int(aid)}/{quote(filename)}")
if not candidates:
raise LookupError("Attachment missing id and content_url.")
return candidates
def download_attachment_bytes(
*,
url: str,
api_key: str,
impersonate_user: str | None = None,
timeout: int = 120,
fallback_urls: list[str] | None = None,
) -> bytes:
headers = {
"X-Redmine-API-Key": api_key,
"Accept": "*/*",
}
if impersonate_user:
headers["X-Redmine-Switch-User"] = impersonate_user
urls = [url] + [u for u in (fallback_urls or []) if u != url]
last_err: Exception | None = None
for u in urls:
req = urllib_request.Request(u, headers=headers, method="GET")
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except urllib_error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:200]
last_err = RuntimeError(f"HTTP {exc.code} downloading attachment ({u}): {detail}")
continue
except urllib_error.URLError as exc:
last_err = RuntimeError(f"Could not download attachment ({u}): {exc.reason}")
continue
raise last_err or RuntimeError("Attachment download failed")
def upload_file_token(
*,
base_url: str,
api_key: str,
path: Path,
filename: str | None = None,
impersonate_user: str | None = None,
timeout: int = 120,
) -> dict[str, Any]:
"""POST raw file to /uploads.json?filename=… Returns upload object with token."""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"File not found: {path}")
name = filename or path.name
data = path.read_bytes()
q = urllib_parse.urlencode({"filename": name})
url = f"{base_url.rstrip('/')}/uploads.json?{q}"
headers = {
"X-Redmine-API-Key": api_key,
"Content-Type": "application/octet-stream",
"Accept": "application/json",
}
if impersonate_user:
headers["X-Redmine-Switch-User"] = impersonate_user
req = urllib_request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
except urllib_error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"HTTP {exc.code} uploading file: {detail}") from exc
except urllib_error.URLError as exc:
raise RuntimeError(f"Could not upload file: {exc.reason}") from exc
if not raw:
raise RuntimeError("Empty response from /uploads.json")
payload = json.loads(raw.decode("utf-8"))
upload = payload.get("upload") if isinstance(payload, dict) else None
if not isinstance(upload, dict) or not upload.get("token"):
raise RuntimeError(f"Unexpected upload response: {payload!r}")
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload.setdefault("filename", name)
upload.setdefault("content_type", content_type)
return upload
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""OCR / text extract helpers for scanned ticket attachments.
Primary path: pdftotext (fast, works on digital PDFs).
Fallback: pdftoppm + tesseract for image-heavy scans.
Also handles common image attachments via tesseract.
Does not write to Redmine — callers queue subject/notes drafts.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
def _run(cmd: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def extract_text_from_pdf(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
"""Return (text, method). method is pdftotext|tesseract|empty."""
path = Path(path)
text = ""
pdftotext = shutil.which("pdftotext")
if pdftotext:
proc = _run([pdftotext, "-layout", "-nopgbrk", str(path), "-"], timeout=60)
text = (proc.stdout or "").strip()
if len(re.sub(r"\s+", "", text)) >= 40:
return text[:max_chars], "pdftotext"
# OCR fallback: first 2 pages as PNG
pdftoppm = shutil.which("pdftoppm")
tesseract = shutil.which("tesseract")
if not pdftoppm or not tesseract:
return text[:max_chars] if text else "", "empty" if not text else "pdftotext-thin"
with tempfile.TemporaryDirectory(prefix="rtl-ocr-") as td:
out_prefix = Path(td) / "page"
_run([pdftoppm, "-png", "-f", "1", "-l", "2", "-r", "200", str(path), str(out_prefix)], timeout=90)
chunks: list[str] = []
for png in sorted(Path(td).glob("page*.png")):
proc = _run([tesseract, str(png), "stdout", "-l", "eng"], timeout=90)
if proc.stdout:
chunks.append(proc.stdout.strip())
ocr_text = "\n\n".join(chunks).strip()
if ocr_text:
return ocr_text[:max_chars], "tesseract"
return text[:max_chars] if text else "", "empty"
def extract_text_from_image(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
tesseract = shutil.which("tesseract")
if not tesseract:
return "", "empty"
proc = _run([tesseract, str(path), "stdout", "-l", "eng"], timeout=90)
text = (proc.stdout or "").strip()
return (text[:max_chars], "tesseract") if text else ("", "empty")
def extract_text_from_file(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".pdf":
return extract_text_from_pdf(path, max_chars=max_chars)
if suffix in {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp", ".gif"}:
return extract_text_from_image(path, max_chars=max_chars)
if suffix in {".txt", ".md", ".csv"}:
try:
return path.read_text(encoding="utf-8", errors="replace")[:max_chars], "read"
except OSError:
return "", "empty"
return "", "empty"
_PERSON_LINE = re.compile(
r"^(?:name|employee|applicant|patient|to|for)\s*[:\-]\s*(.+)$",
re.I | re.M,
)
_COMPANY_LINE = re.compile(
r"^(?:company|organization|employer|vendor|client)\s*[:\-]\s*(.+)$",
re.I | re.M,
)
_DATE_LINE = re.compile(
r"\b((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b",
re.I,
)
_DOC_HINTS = (
(r"\bw-?2\b", "W-2"),
(r"\bw-?4\b", "W-4"),
(r"\bi-?9\b", "I-9"),
(r"\bdriver.?s?\s+license\b|\bdl\b", "Driver License"),
(r"\bpassport\b", "Passport"),
(r"\binvoice\b", "Invoice"),
(r"\bpacking\s+slip\b", "Packing Slip"),
(r"\bbill\s+of\s+lading\b|\bbol\b", "Bill of Lading"),
(r"\bresume\b|\bcv\b", "Resume"),
(r"\boffer\s+letter\b", "Offer Letter"),
(r"\bnda\b|non-?disclosure", "NDA"),
(r"\bhandbook\b", "Handbook"),
(r"\bdirect\s+deposit\b", "Direct Deposit Form"),
)
def suggest_subject_from_text(
text: str,
*,
original_subject: str = "",
max_len: int = 120,
) -> str:
"""Heuristic subject for scanned-doc tickets."""
clean = re.sub(r"[ \t]+", " ", text or "")
lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
# Drop very short / noise lines
content_lines = [ln for ln in lines if len(ln) >= 4 and not re.fullmatch(r"[\d\W]+", ln)][:40]
doc_type = "Scanned document"
joined = "\n".join(content_lines[:30])
for pat, label in _DOC_HINTS:
if re.search(pat, joined, re.I):
doc_type = label
break
person = None
m = _PERSON_LINE.search(joined)
if m:
person = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60]
company = None
m = _COMPANY_LINE.search(joined)
if m:
company = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60]
# Fallback: first substantial title-ish line
title_line = None
for ln in content_lines[:8]:
if len(ln) < 8 or len(ln) > 80:
continue
if re.search(r"https?://|@|page\s+\d", ln, re.I):
continue
title_line = ln
break
date = None
dm = _DATE_LINE.search(joined)
if dm:
date = dm.group(1)
parts: list[str] = [doc_type]
if person:
parts.append(f"— {person}")
elif company:
parts.append(f"— {company}")
elif title_line and title_line.lower() not in doc_type.lower():
parts.append(f"— {title_line[:50]}")
if date and date not in " ".join(parts):
parts.append(f"({date})")
subject = " ".join(parts)
subject = re.sub(r"\s+", " ", subject).strip()
if len(subject) > max_len:
subject = subject[: max_len - 1].rstrip() + "…"
if not subject or subject == "Scanned document":
# last resort: original if not generic
orig = (original_subject or "").strip()
if orig and not re.search(r"scanned\s+document", orig, re.I):
return orig[:max_len]
if title_line:
return f"Scanned document — {title_line[:80]}"[:max_len]
return subject
def attachment_is_ocr_candidate(att: dict[str, Any]) -> bool:
name = str(att.get("filename") or att.get("name") or "").lower()
ctype = str(att.get("content_type") or "").lower()
if name.endswith(".pdf") or "pdf" in ctype:
return True
if any(name.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp")):
return True
if ctype.startswith("image/"):
return True
return False
def looks_like_generic_scan_subject(subject: str) -> bool:
s = (subject or "").strip().lower()
if not s:
return True
return bool(
re.search(
r"scanned\s+document|scan\s+from|hp\s+scan|scanner|untitled|document\s+from\s+scan",
s,
)
)
def build_ocr_note(
*,
issue_id: int,
files: list[dict[str, str]],
proposed_subject: str,
) -> str:
lines = [
"=== OCR / attachment review (agent) ===",
f"issue_id: {issue_id}",
f"proposed_subject: {proposed_subject}",
"",
]
for f in files:
lines.append(f"## {f.get('filename')} ({f.get('method')})")
lines.append("")
snippet = (f.get("text") or "")[:2500].strip()
lines.append(snippet or "_(no extractable text)_")
lines.append("")
lines.append("_Generated by redmine_ticket_ocr; verify before customer-facing use._")
return "\n".join(lines) + "\n"
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Markdown rendering + pager helpers for cached Redmine tickets."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
def _nested_name(issue: dict[str, Any], key: str) -> str:
value = issue.get(key)
if isinstance(value, dict):
name = value.get("name")
return str(name) if name is not None else ""
return "" if value is None else str(value)
def _helpdesk(issue: dict[str, Any]) -> dict[str, Any]:
hd = issue.get("helpdesk_ticket") or {}
return hd if isinstance(hd, dict) else {}
def _contact(issue: dict[str, Any]) -> dict[str, Any]:
contact = _helpdesk(issue).get("contact") or {}
return contact if isinstance(contact, dict) else {}
def render_ticket_markdown(issue: dict[str, Any]) -> str:
issue_id = issue.get("id")
subject = str(issue.get("subject") or "").strip()
status = _nested_name(issue, "status")
project = _nested_name(issue, "project")
category = _nested_name(issue, "category")
assigned = _nested_name(issue, "assigned_to") or "(unassigned)"
author = _nested_name(issue, "author")
priority = _nested_name(issue, "priority")
created = issue.get("created_on") or ""
updated = issue.get("updated_on") or ""
hd = _helpdesk(issue)
contact = _contact(issue)
from_addr = hd.get("from_address") or contact.get("email") or ""
contact_id = contact.get("id")
company = contact.get("company") or ""
contact_name = contact.get("name") or ""
description = str(issue.get("description") or "").strip()
redmine_url = f"https://rm.ldrprep.com/issues/{issue_id}" if issue_id else ""
lines = [
f"# #{issue_id} — {subject}",
"",
"## Meta",
"",
f"- **Project:** {project}",
f"- **Status:** {status}",
f"- **Priority:** {priority}",
f"- **Category:** {category or '(none)'}",
f"- **Assignee:** {assigned}",
f"- **Author:** {author}",
f"- **Created:** {created}",
f"- **Updated:** {updated}",
f"- **Redmine:** {redmine_url}",
"",
"## Helpdesk / contact",
"",
f"- **From:** {from_addr}",
f"- **Contact ID:** {contact_id if contact_id is not None else '(none)'}",
f"- **Contact name:** {contact_name or '(none)'}",
f"- **Company:** {company or '(none)'}",
"",
"## Detected entities (heuristic)",
"",
]
try:
from redmine_ticket_entities import extract_entities_from_issue, format_entities_line
ents = extract_entities_from_issue(issue)
if ents:
for e in ents:
conf = e.get("confidence") or "high"
lines.append(f"- **{e.get('type')}:** `{e.get('value')}` ({conf})")
else:
lines.append("_(none)_")
except Exception:
lines.append("_(entity extractor unavailable)_")
lines.extend(
[
"",
"## Description",
"",
description or "_(empty)_",
"",
"## Journals",
"",
]
)
journals = issue.get("journals") or []
if not journals:
lines.append("_(no journals)_")
else:
for journal in journals:
if not isinstance(journal, dict):
continue
jid = journal.get("id")
jcreated = journal.get("created_on") or ""
user = journal.get("user") or {}
uname = user.get("name") if isinstance(user, dict) else ""
notes = str(journal.get("notes") or "").strip()
details = journal.get("details") or []
lines.append(f"### Journal #{jid} — {jcreated} — {uname}")
lines.append("")
if details:
for detail in details:
if not isinstance(detail, dict):
continue
lines.append(
f"- change: {detail.get('property')}/{detail.get('name')}: "
f"{detail.get('old_value')!r} → {detail.get('new_value')!r}"
)
lines.append("")
if notes:
lines.append(notes)
else:
lines.append("_(no notes)_")
lines.append("")
lines.append("---")
lines.append(f"_Rendered from cache; source updated_on={updated}_")
lines.append("")
return "\n".join(lines)
def markdown_cache_path(cache_dir: Path, project: str, issue_id: int | str) -> Path:
return Path(cache_dir) / project / "markdown" / f"{issue_id}.md"
def write_ticket_markdown(
issue: dict[str, Any],
cache_dir: Path,
project: str,
*,
force: bool = False,
) -> Path:
"""Write markdown cache if missing or ticket updated_on changed (via sidecar stamp)."""
issue_id = issue["id"]
path = markdown_cache_path(cache_dir, project, issue_id)
stamp_path = path.with_suffix(".md.updated_on")
updated_on = str(issue.get("updated_on") or "")
path.parent.mkdir(parents=True, exist_ok=True)
if (
not force
and path.is_file()
and stamp_path.is_file()
and stamp_path.read_text(encoding="utf-8").strip() == updated_on
):
return path
path.write_text(render_ticket_markdown(issue), encoding="utf-8")
stamp_path.write_text(updated_on + "\n", encoding="utf-8")
return path
def resolve_pager(preferred: str = "auto") -> list[str]:
"""Return argv prefix to display markdown (pager program + flags)."""
preferred = (preferred or "auto").lower()
if preferred == "cat":
return ["cat"]
if preferred in {"glow", "bat", "batcat"}:
path = shutil.which(preferred)
if not path:
raise FileNotFoundError(f"Pager {preferred!r} not found on PATH")
if preferred == "glow":
return [path]
return [path, "--language", "markdown", "--style", "plain"]
# auto
for name, args in (
("glow", []),
("bat", ["--language", "markdown", "--style", "plain"]),
("batcat", ["--language", "markdown", "--style", "plain"]),
):
path = shutil.which(name)
if path:
return [path, *args]
return ["cat"]
def display_markdown_file(path: Path, pager: str = "auto") -> int:
cmd = resolve_pager(pager) + [str(path)]
# Prefer streaming to user terminal
env = os.environ.copy()
try:
completed = subprocess.run(cmd, check=False, env=env)
return int(completed.returncode)
except FileNotFoundError:
print(path.read_text(encoding="utf-8"))
return 0
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Portable contacts live entry point. No shell evaluation of credentials.
set -euo pipefail
ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
exec python3 "$ROOT/redmine_cli_runtime.py" contacts live "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Source this file; no network calls and no config/secret loading.
_redmine_completion_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$_redmine_completion_root/redmine_contacts_completion.sh"
_redmine_runtime_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}"
local common="--help --show-config --doctor --doctor-nmi --env-file --base-cache --base-url --project --mode --api-key-file"
COMPREPLY=()
case "$prev" in
--env-file|--cache|--cache-dir|--base-cache|--api-key-file|--config|--body-file)
while IFS= read -r entry; do COMPREPLY+=("$entry"); done < <(compgen -f -- "$cur")
return ;;
--mode) COMPREPLY=( $(compgen -W 'production dev' -- "$cur") ); return ;;
--source) COMPREPLY=( $(compgen -W 'cache api auto' -- "$cur") ); return ;;
esac
if [[ "${COMP_WORDS[0]}" == *rcontacts-live ]]; then
_redmine_contacts_completion
COMPREPLY+=( $(compgen -W "$common" -- "$cur") )
else
local commands="sync search view walk materialize-queues note customer-email set-fields meta-refresh contacts-sync contacts-search contact-show contact-set ticket-contact attachments entities browse tui textual-tui triage ocr-rename categories control-tag draft spam-check close-if-spam tag-maybe-customer decompose"
local flags="--cache-dir --config --allow-prod-writes --where --open-only --max-tickets --issue-id --contact-id --json --jsonl --ids-only --fetch --apply --private --body --body-file --remote-updated-since --remote-updated-before --remote-status-id --dry-run --quiet --run-log"
COMPREPLY=( $(compgen -W "$commands $common $flags" -- "$cur") )
fi
}
complete -F _redmine_runtime_complete rtl rtd rtickets-live rcontacts-live ./bin/rtl ./bin/rtd ./bin/rtickets-live ./bin/rcontacts-live
unset _redmine_completion_root
+3 -4
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Muscle-memory wrapper: LAN/dev customer-service cache + base URL. # Portable tickets dev entry point. No shell evaluation of credentials.
# Usage: rtd note --issue-id 39305 --body 'test' --apply
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/dev.json" "$@" exec python3 "$ROOT/redmine_cli_runtime.py" tickets dev "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Portable tickets live entry point. No shell evaluation of credentials.
set -euo pipefail
ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@"
+3 -7
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Muscle-memory wrapper: prod customer-service cache + base URL. # Portable tickets live entry point. No shell evaluation of credentials.
# Usage: rtl search --where 'assigned_to IS NULL' --max-tickets 10
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
# rtl is the production Redmine wrapper. Keep an explicitly supplied mode, but exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@"
# never silently default its NMI briefs to the dev profile.
export NMI_MODE="${NMI_MODE:-production}"
exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/live.json" "$@"
+5
View File
@@ -0,0 +1,5 @@
{
"base-url": "http://192.168.50.170",
"project": "customer-service",
"cache-dir": "/tmp/redmine/cache-dev/redmine_tickets"
}
+87
View File
@@ -0,0 +1,87 @@
# Redmine CLI runtime
The live wrappers are deliberately short:
```bash
bin/rcontacts-live search acme
bin/rtickets-live search --where 'assigned_to IS NULL'
bin/rtl search --where 'assigned_to IS NULL' # short live alias
bin/rtd search --where 'assigned_to IS NULL' # explicit LAN/dev lane
```
`--help` works without credentials. The wrappers resolve configuration independently of the current working directory and pass ordinary arguments unchanged to `redmine_contacts.py` or `redmine_tickets.py`.
## Diagnose configuration without API traffic
```bash
bin/rcontacts-live --doctor
bin/rtickets-live --show-config
bin/rtl --doctor-nmi
```
Diagnostics show effective paths and each setting’s source. Secrets are never printed. `network_checked: false` means readiness is local; it does not prove remote authentication.
## Precedence and files
Highest priority wins:
1. wrapper CLI options;
2. exported environment;
3. selected Redmine env file;
4. ticket JSON/YAML `--config` values;
5. defaults.
Select an env file with `--env-file PATH` or `REDMINE_ENV_FILE`. Otherwise the first existing file is used:
- live: checkout `.env`, legacy `bin/.env`, `~/.redmine.live`;
- dev: checkout `.env.dev`, `bin/.env.dev`, `~/.redmine.dev`.
Dev never falls back to the live file. An explicitly selected missing, empty, or malformed file is an error. Files use `NAME=value` and are parsed as data; shell commands are not evaluated.
```text
REDMINE_API_KEY required for API operations
REDMINE_BASE_URL live default https://rm.ldrprep.com
REDMINE_PROJECT default customer-service
REDMINE_BASE_CACHE common mutable-data root
REDMINE_CONTACTS_CACHE exact full-CRM snapshot JSON file
REDMINE_TICKETS_CACHE exact ticket-cache directory
REDMINE_LOCK_TIMEOUT writer wait, seconds; default 60
REDMINE_ENV_FILE explicit env profile
REDMINE_API_KEY_FILE file containing only the Redmine key
```
NMI bridge variables (`NMI_CLI`, `NMI_ENV_FILE`, `NMI_MODE`, endpoint/credential variables) pass through to PHP. `rtl` defaults NMI to production; `rtd` defaults it to dev. `--doctor-nmi` runs the actual PHP resolver offline.
## Cache layout
`REDMINE_BASE_CACHE` chooses the common root, defaulting to checkout `cache/` live and `cache-dev/` dev.
- Tickets normally use `<root>/redmine_tickets/`.
- Other endpoint/project combinations get deterministic distinct ticket directories.
- Fresh contacts setups use `<root>/redmine_contacts/<host>_<project>.json`.
- If historical `<root>/redmine_contacts` is already a **file** for normal live use, the wrapper preserves and uses it. There is no automatic migration.
- Tickets’ `<ticket-cache>/contacts/` mirror is a different per-contact format, not the full contacts snapshot.
Exact `--cache-dir DIR` (tickets) and `--cache FILE` (contacts) win. Contacts rejects a cache directory because `redmine_contacts.py` expects one JSON file.
`ticket-contact` prints a shell-quoted full-CRM handoff that retains endpoint, project, env profile, API-key-file context, and cache file without putting a key in argv.
## Concurrency and cron
Writers use POSIX advisory locks around read/modify/write transactions and unique same-directory temporary files followed by atomic replacement. A process’s death releases its kernel lock; the persistent `.lock` file is only an inode anchor. A later holder removes matching temporary residue left by a killed writer.
Contention is visible on stderr. After `REDMINE_LOCK_TIMEOUT`, the process exits nonzero rather than making a cron failure silent.
```cron
REDMINE_ENV_FILE=/etc/ldr/redmine-live.env /opt/redmine/bin/rtickets-live sync --remote-updated-since 6h --run-log --quiet
```
## Completion and safety
```bash
source bin/redmine-runtime-completion.bash
```
This registers all four wrappers and includes the established contacts completion surface.
The wrappers do not weaken write safety: production ticket writes still require `--allow-prod-writes`; customer email still requires its long confirmation; NMI retains its independent mutation gates.
@@ -0,0 +1,60 @@
# Portable Redmine and NMI CLI runtime
## Approved direction
Improve the existing tools without migrating caches or weakening mutation gates. Preserve existing unrelated working-tree changes. No package installations, production mutations, cron changes, credential rewrites, or cache migration.
## Commands and configuration
- Keep `bin/rcontacts-live`; introduce `bin/rtickets-live`; make `bin/rtl` the short alias using the same resolver. Keep `rtd` explicitly development-oriented.
- Centralize Redmine wrapper setup in a Python standard-library module. Shell entry points locate their checkout reliably and invoke it. Resolve paths independently of the calling working directory.
- Precedence: explicit CLI options > exported environment > selected env file > documented defaults. Empty required values are missing. Preserve legacy JSON/YAML ticket config support with documented interactions; explicit CLI still wins.
- Select env file explicitly with `--env-file` or `REDMINE_ENV_FILE`; otherwise search the checkout `.env`, legacy `bin/.env`, then `~/.redmine.live` (development uses a distinct file, never silently loads live credentials). Report searched/selected paths. An explicitly selected missing/malformed file is an error, not permission to fall back. Parse key/value data, never execute arbitrary shell code.
- Expose `--show-config` (redacted resolved values and sources, no API calls) and `--doctor` (offline validation with nonzero status for problems). Help works without credentials; cache-only operations retain current credential requirements rather than globally demanding credentials at startup.
- Default live Redmine URL: `https://rm.ldrprep.com`, project `customer-service`; default NMI mode production. Explicit dev selection is visible on stderr. Normal machine-readable stdout is not polluted by banners.
- Preserve existing `--apply`, `--allow-prod-writes`, customer-email confirmations and NMI mutation gates.
## Cache contract
- `REDMINE_BASE_CACHE` selects a common root, default `<checkout>/cache` for live wrappers and `<checkout>/cache-dev` for development. The existing live ticket default remains `<checkout>/cache/redmine_tickets` for customer-service.
- Tickets: `<root>/redmine_tickets` for the default production customer-service project; other endpoints/projects use distinct deterministic paths unless explicitly overridden. Exact `--cache-dir` wins. Contacts CLI: `<root>/redmine_contacts/<host>_<project>.json`; exact `--cache` wins and must be a file path.
- Tickets' internal contacts mirror remains `<ticket-cache>/contacts`; it is a different schema from the contacts CLI snapshot. Diagnostics name both and explain this distinction. Printed handoffs retain resolved endpoint, project, contacts cache, and selected configuration context without printing credentials.
- Reuse the resolved API key consistently, including `--api-key-file` branches. No secret goes in a subprocess argument or diagnostic output.
## NMI contract
- Improve `mrscClient-php/bin/nmi` and shared client configuration only as necessary. Preserve existing uncommitted edits in `bin/nmi`, `AgentApi.php`, `MutationGuard.php`, and `.gitignore`.
- `nmi --show-config` / `nmi --doctor` are offline and use the same configuration resolver as actual commands. Also support natural command forms if convenient. Report mode, endpoint, selected config/env paths, key names/source/presence (not values).
- Explicit CLI wins over exported environment; exported variables win over env-file values. Canonical production keys include `NMI_ENDPOINT_PRODUCTION`, `NMI_MRSC_ACCESS_CODE_PRODUCTION`, `NMI_SECRETKEY_PRODUCTION`; existing generic/alternate names stay supported and are documented.
- Default production mode is consistent across direct CLI and Redmine bridge; dev must be explicit and visible. Discover default env file relative to checkout (`examples/modern/.env`, with documented user/repo candidates), not `/home/jason`.
- Redmine bridge discovers NMI via `NMI_CLI`, PATH, or sibling checkout. `NMI_ENV_FILE` is optional when valid exported credentials exist; explicit file errors remain fatal. `rtickets-live --doctor-nmi` delegates to the actual NMI diagnostic path without API requests, using the same command/env as real lookups. Bound subprocess timeouts and report nonzero failures clearly.
## Concurrent cache safety
- Use standard-library POSIX advisory locking on persistent lock files; do not infer liveness from the existence of a file and do not unlink a live lock inode.
- Protect read-modify-write transactions by taking the lock before loading current manifest state. Do not merely lock the final write of an old in-memory manifest.
- Use unique temporary files in the destination directory, flush/fsync as appropriate, then atomic replace for manifests and record JSON. Clean up failed temporary files. OS closes/releases locks on exit, including forced termination.
- A contending writer reports that it is waiting on stderr, then fails with an actionable error on a bounded configurable timeout (`REDMINE_LOCK_TIMEOUT`, default 60 seconds). Invalid timeouts fail explicitly. Avoid whole-session TUI locks.
- Full contacts snapshots require serialization across fetch/write where necessary to avoid an older fetch replacing a newer one. Preserve snapshot semantics (filtered caches are deliberate), not an accidental append-only merge.
- No manifest chunking or SQLite migration in this work. Regression tests cover concurrent distinct updates, reader-safe atomic replacement, timeout visibility, and process-death release.
## Implementation plan
1. Add failing clean-environment wrapper tests in `tests/test_cli_runtime.py`: help, missing/empty config, env precedence, paths with spaces, working-directory independence, cache override, redaction, explicit dev, and alias behavior. Implement `redmine_cli_runtime.py` and wrapper entries. Extend completion and operator docs.
2. Add NMI PHP subprocess tests (standalone PHP or Python unittest; no new packages). Exercise actual CLI with test-only config and no network. Implement diagnostic/config changes and update completion/help. Ensure pre-existing mutation logic is unchanged.
3. Add `tests/test_cache_concurrency.py` with subprocess writers and killed lock holders. Implement shared `redmine_cache_io.py` and integrate ticket state store, record writes, ticket contacts cache, and full CRM snapshot writes. Run existing focused cache tests.
4. Integrate Redmine-to-NMI profile resolution and diagnostics after NMI CLI contract exists. Test with a fake NMI executable recording argv/env; separately run real NMI offline doctor. Correct contacts handoff propagation and API-key-file consistency.
5. Run focused existing suites plus new tests, Python compile checks, PHP lint, shell syntax/completion smoke probes. Execute wrappers from an unrelated directory under a minimal environment using isolated temporary config/cache fixtures. Never touch real production data for tests.
6. Review diffs, preserve baseline edits, record exact verification and any residual limitations in `docs/cli_runtime.md`; update relevant procedural skill.
## Acceptance checklist
- [ ] Contacts and tickets wrappers run/help cleanly and consistently.
- [ ] Missing configuration explains required variables and lookup paths.
- [ ] Cache root and per-tool override semantics are verified.
- [ ] Tickets contact handoffs preserve runtime context and expose distinct formats.
- [ ] Direct NMI and bridge agree on mode, credentials and file precedence.
- [ ] Offline bridge preflight exercises actual NMI resolver.
- [ ] Concurrent cache updates and abandoned-lock recovery pass real process tests.
- [ ] Existing safety gates remain unchanged.
- [ ] Usage, completion and verification results are documented.
+3
View File
@@ -0,0 +1,3 @@
- [ ] No good way to link voicemail/call transcriptins to the contact_id of the called (the tickets are from the voip.ms service)
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""No-op FILTER-2 worker for exercising redmine_contacts.py loop-run.
Reads the loop bundle on stdin and returns false so the runner applies the
profile default tag. This proves the conveyor belt without classifying or
mutating Redmine directly.
"""
from __future__ import annotations
import json
import sys
def main() -> int:
json.load(sys.stdin)
json.dump(
{
"result": False,
"summary": "noop FILTER-2 did not classify this contact",
"mutations": [],
},
sys.stdout,
)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
FILTER-2 worker: spam / promo scorer for the contact triage loop.
Reads the loop bundle on stdin and returns tag recommendations
based on simple evidence scoring for promotional and spam senders.
Contract:
- Input: JSON {profile, contact, issues, runtime}
- Output: JSON {result: bool, summary: str, mutations?: list[str]}
Scoring signals (from contact_triage_loop_raw_notes):
- Sender hints (no-reply, noreply, deals, promotions) → +1
- Message mentions discount/coupon/deal language → +1
Threshold: score >= 2 → tag as spam + promo (or just spam for v0)
Otherwise: result=false (runner will add default_tag)
This worker never mutates Redmine directly; it only recommends.
The runner owns tag application and ai-checked fallback.
"""
from __future__ import annotations
import json
import re
import sys
from typing import Any
SPAM_HINTS = re.compile(
r"\b(no-?reply|noreply|deals?|promotions?|marketing|newsletter|offers?)\b",
re.IGNORECASE,
)
DEAL_LANGUAGE = re.compile(
r"\b(discount|coupon|deal|promo|sale|offer|free|save|% off|limited time)\b",
re.IGNORECASE,
)
def score_contact(bundle: dict[str, Any]) -> tuple[bool, str, list[str]]:
contact = bundle.get("contact", {})
issues = bundle.get("issues", [])
profile = bundle.get("profile", {})
runtime = bundle.get("runtime", {})
dry_run = runtime.get("dry_run", True)
contact_id = contact.get("id")
sender_hints = 0
deal_hits = 0
# Quick contact-level signals (if any metadata is present)
# For now we rely on helpdesk messages; extend later if contact fields improve.
for issue in issues:
summary = issue.get("issue_summary", {})
subject = (summary.get("subject") or "").lower()
# Full messages are in the bundle if fetch_full_issues was true; here we use what was provided.
# The runner already sanitizes; we still run our patterns.
if SPAM_HINTS.search(subject):
sender_hints += 1
if DEAL_LANGUAGE.search(subject):
deal_hits += 1
# Also scan a small amount of message text if present in the payload
# (the runner passes compact issue data; we keep it simple)
for msg in issue.get("messages", [])[:3]: # light scan
text = (msg.get("body") or "")[:500].lower()
if SPAM_HINTS.search(text):
sender_hints += 1
if DEAL_LANGUAGE.search(text):
deal_hits += 1
score = sender_hints + deal_hits
tags_to_add: list[str] = []
if score >= 2:
tags_to_add = ["spam", "promo"]
summary = (
f"Score {score} (sender hints={sender_hints}, deal language={deal_hits}). "
f"Recommending spam + promo tags for contact {contact_id}."
)
result = True
else:
summary = (
f"Score {score} (sender hints={sender_hints}, deal language={deal_hits}). "
f"Insufficient evidence for spam/promo on contact {contact_id}."
)
result = False
# mutations are informational only in v0; runner applies the tags
mutations = [f"contact:{contact_id} add {', '.join(tags_to_add)}"] if tags_to_add else []
# In strict dry-run mode we still return the recommendation so the operator can review.
return result, summary, mutations
def main() -> int:
try:
bundle = json.load(sys.stdin)
except Exception as exc:
json.dump(
{
"result": False,
"summary": f"FILTER-2 failed to parse input: {exc}",
"mutations": [],
},
sys.stdout,
)
sys.stdout.write("\n")
return 1
result, summary, mutations = score_contact(bundle)
json.dump(
{
"result": result,
"summary": summary,
"mutations": mutations,
},
sys.stdout,
indent=2,
)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,21 @@
{
"name": "noop-ai-checked",
"default_tag": "ai-checked",
"remove_tags": [],
"filter_1": {
"type": "builtin",
"name": "fixed-ticket-limit",
"limit": 5
},
"ticket_fetch": {
"fetch_full_issues": false,
"message_limit": 25,
"sanitize_text": true
},
"filter_2": {
"type": "command",
"argv": ["python3", "filters/noop_filter2.py"],
"timeout_seconds": 30,
"allow_mutation": false
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "spam-scorer",
"default_tag": "ai-checked",
"remove_tags": [],
"filter_1": {
"type": "builtin",
"name": "fixed-ticket-limit",
"limit": 5
},
"ticket_fetch": {
"fetch_full_issues": false,
"message_limit": 25,
"sanitize_text": true
},
"filter_2": {
"type": "command",
"argv": ["python3", "filters/spam_promo_scorer.py"],
"timeout_seconds": 30,
"allow_mutation": false
}
}
+132
View File
@@ -0,0 +1,132 @@
{
"version": 1,
"project": "customer-service",
"description": "Known spam / clutter profiles seeded from verified production broom passes and legacy Rebot KnownSpam rules. Customer and maybe-customer contact safety gates must be applied by the tool before any close.",
"universal_safety_gates": {
"skip_contact_tags": [
"Customer",
"maybe-customer"
],
"require_open_ticket": true,
"require_project": "customer-service",
"write_impersonate_user": "rebot"
},
"profiles": [
{
"name": "globalindustrial-promos",
"category": "spam-promotional",
"confidence": "high",
"source": "verified-production-and-rebot",
"sender_regexes": [
"^reply@globalindustrial\\.com$"
],
"subject_regexes": [
"(?i)\\b(clearance|sale|save|% off|bestsellers|fans|blowers|cooling|storage|power tools|kee safety|roadway safety|advanced ac)\\b"
]
},
{
"name": "vistaprint-promos",
"category": "spam-promotional",
"confidence": "high",
"source": "verified-production",
"sender_regexes": [
"^noreply@mail\\.vistaprint\\.com$"
],
"subject_regexes": [
"(?i)\\b(sale|save|% off|email exclusive|ends|qualifying orders|favorite sale|customer faves|buy more)\\b"
]
},
{
"name": "qnap-newsletters",
"category": "spam-promotional",
"confidence": "high",
"source": "verified-production",
"sender_regexes": [
"^enews-noreply@qnap\\.com$"
],
"subject_regexes": [
"(?i)\\b(qnap|photodirector|nas|security advisory|product of the year|jc-star|file backup|heic)\\b|^—$|^61|New$"
]
},
{
"name": "business-journals-promos",
"category": "spam-promotional",
"confidence": "high",
"source": "verified-production",
"sender_regexes": [
"^replycincinnati@engaged\\.bizjournals\\.com$",
"^replycincinnati@partner\\.bizjournals\\.com$",
"^acollett@bizjournals\\.com$"
],
"subject_regexes": [
"(?i)\\b(events|nominations|business summit|brand next to key business lists|chambercomp|workers.? comp)\\b"
]
},
{
"name": "legacy-rebot-promotional-senders",
"category": "spam-promotional",
"confidence": "medium",
"source": "legacy-rebot-knownspam",
"sender_regexes": [
"^.*@.*roku\\.com$",
"^no\\.reply@masterclass\\.com$",
"^donotreply@walmart\\.com$",
"^.*@uber\\.com$",
"^no-reply@inhome\\.walmart\\.com$",
"^.*wingsandrings\\.com$",
"^.*@tryfinally\\.co$",
"^.*crackerbarrel\\.com$",
"^.*shop\\.tiktok\\.com$",
"^noreply_samsung@sea\\.samsung\\.com$",
"^noreply@email\\.crunchbase\\.com$",
"^.*webstaurantstore\\.com$",
"^support@helium10\\.com$",
"^noreply@popmenu\\.com$",
"^info@go\\.cbts\\.com$",
"^noreply@rush-delivery\\.com$"
],
"subject_regexes": []
},
{
"name": "legacy-rebot-operational-clutter",
"category": "automated-operational",
"confidence": "medium",
"source": "legacy-rebot-knownspam",
"sender_regexes": [
"^.*@indeedemail\\.com$",
"^no-reply@indeed\\.com$",
"mailnull@amazon\\.com",
"^info@emailbiz\\.dukeenergy\\.com$"
],
"subject_regexes": [
"(?i)Your Amazon\\.com order.*has shipped",
"(?i)^Your Duke energy statement is ready",
"(?i)^Your Amazon\\.com order.*",
"(?i)^UPS Billing: Payment Received.*",
"(?i)^UPS Billing:.*new invoice.*",
"(?i)^.applied on indeed.*",
"(?i)^See results for the assessments you sent to.*",
"(?i)^Reminder: Leave feedback on your interview with.*",
"(?i)^Pekin AutoPay Payment.*",
"(?i)^Notification of Active Listings Report availability"
],
"default_action": "report-only-until-approved"
},
{
"name": "legacy-rebot-receipts-routing-reference",
"category": "routing-reference",
"confidence": "medium",
"source": "legacy-rebot-receipts-classifier",
"sender_regexes": [
"culliganbillingoh@culliganwaterco\\.com",
"accounting@amshippingsupplies\\.com",
"bookkeeperasl@gmail\\.com",
"bounces-noreply@mandrillapp\\.com"
],
"subject_regexes": [
"(?i)You have a new bill from altafiber"
],
"default_action": "do-not-close-as-spam-route-later"
}
]
}
+125
View File
@@ -0,0 +1,125 @@
"""POSIX local-filesystem cache transactions; persistent locks, atomic JSON.
Locks protect read/modify/write, not only replacement. File existence never
indicates ownership. Kernel locks are released even when the holder is killed.
"""
from __future__ import annotations
from contextlib import contextmanager
import fcntl
import json
import math
import os
from pathlib import Path
import sys
import tempfile
import threading
import time
class CacheError(RuntimeError):
pass
_local = threading.local()
def lock_timeout():
raw = os.environ.get('REDMINE_LOCK_TIMEOUT','60')
try:
value=float(raw)
if not math.isfinite(value) or value < 0:
raise ValueError()
return value
except ValueError as exc:
raise CacheError('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.') from exc
@contextmanager
def cache_lock(target, timeout=None):
target=Path(target).resolve()
seconds=lock_timeout() if timeout is None else float(timeout)
if not math.isfinite(seconds) or seconds < 0:
raise CacheError('Cache lock timeout must be finite and nonnegative.')
held=getattr(_local,'held',None)
if held is None:
held=_local.held={}
key=(os.getpid(),str(target))
if key in held:
yield
return
path=target.with_name(target.name+'.lock')
path.parent.mkdir(parents=True,exist_ok=True)
fd=os.open(path,os.O_RDWR|os.O_CREAT,0o600)
acquired=False
start=time.monotonic()
warned=False
try:
while True:
try:
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
acquired=True
held[key]=fd
# A killed writer may leave its same-directory temporary file.
# Once this lock is ours, no live writer for this target can own
# one, so removing matching leftovers is safe.
for stale in target.parent.glob('.' + target.name + '.*.tmp'):
try:
stale.unlink()
except FileNotFoundError:
pass
break
except BlockingIOError:
if not warned:
print(f'Waiting for cache lock: {path} (timeout {seconds:g}s)',file=sys.stderr,flush=True)
warned=True
remaining=seconds-(time.monotonic()-start)
if remaining <= 0:
raise CacheError(f'Cache lock timeout after {seconds:g}s: {path}. Another writer is active; retry or increase REDMINE_LOCK_TIMEOUT. Do not delete the lock file.')
time.sleep(min(0.05,remaining))
yield
finally:
if acquired:
held.pop(key,None)
fcntl.flock(fd,fcntl.LOCK_UN)
os.close(fd)
def read_json(path):
path=Path(path)
try:
data=json.loads(path.read_text(encoding='utf-8'))
except FileNotFoundError:
return {}
except (json.JSONDecodeError,UnicodeError) as exc:
raise CacheError(f'Invalid cache JSON: {path}; refusing to overwrite it. Restore or repair this file.') from exc
if not isinstance(data,dict):
raise CacheError(f'Cache JSON must be an object: {path}; refusing to overwrite it.')
return data
def locked_read_json(path):
with cache_lock(path):
return read_json(path)
def atomic_write_json(path,data):
path=Path(path)
with cache_lock(path):
fd,name=tempfile.mkstemp(prefix='.'+path.name+'.',suffix='.tmp',dir=path.parent)
tmp=Path(name)
try:
with os.fdopen(fd,'w',encoding='utf-8') as handle:
json.dump(data,handle,indent=2,sort_keys=True)
handle.write('\n')
handle.flush()
os.fsync(handle.fileno())
# Keep existing shared-file permissions; new sensitive caches are 0600.
if path.exists():
os.chmod(tmp,path.stat().st_mode & 0o777)
os.replace(tmp,path)
finally:
tmp.unlink(missing_ok=True)
def update_manifest_atomic(path,updater):
with cache_lock(path):
current=read_json(path)
updater(current)
atomic_write_json(path,current)
return current
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Portable Redmine entry points. Configuration is data, never shell code."""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent
class ConfigError(ValueError):
pass
def env_values(path):
values = {}
try:
lines = path.read_text(encoding='utf-8').splitlines()
except OSError as exc:
raise ConfigError(f'Cannot read env file {path}: {exc.strerror}') from exc
for n, line in enumerate(lines, 1):
line = line.strip()
if not line or line.startswith('#'):
continue
match = re.fullmatch(r'(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)', line)
if not match:
raise ConfigError(f'Malformed env assignment in {path}:{n}; expected NAME=value (no shell commands).')
key, value = match.groups()
if value.startswith(('"', "'")):
quote = value[0]
end = value.find(quote, 1)
if end < 0 or (value[end+1:].strip() and not value[end+1:].lstrip().startswith('#')):
raise ConfigError(f'Malformed quoted value in {path}:{n}.')
value = value[1:end]
else:
value = re.split(r'\s+#', value, maxsplit=1)[0].strip()
values[key] = value
return values
def absolute(value, base=None):
p = Path(str(value)).expanduser()
return (p if p.is_absolute() else (base or Path.cwd()) / p).resolve()
def split_options(argv):
values, rest = {}, []
takes = {'--env-file','--base-cache','--base-url','--project','--cache-dir','--cache','--api-key-file','--mode'}
switches = {'--show-config','--doctor','--doctor-nmi'}
i, saw_command = 0, False
while i < len(argv):
token = argv[i]
flag, sep, val = token.partition('=')
if token == '--':
rest.extend(argv[i:]); break
if flag in switches:
if sep:
raise ConfigError(f'{flag} does not take a value.')
values[flag] = True
elif flag in takes or (flag == '--config' and not saw_command):
if not sep:
i += 1
if i >= len(argv) or argv[i].startswith('--'):
raise ConfigError(f'{flag} requires a value.')
val = argv[i]
if not val.strip():
raise ConfigError(f'{flag} cannot be empty.')
values[flag] = val
else:
rest.append(token)
if not token.startswith('-'):
saw_command = True
i += 1
return values, rest
def resolve(tool, lane, options, environ=None):
env = dict(os.environ if environ is None else environ)
if tool == 'contacts' and '--cache-dir' in options:
raise ConfigError('Contacts uses --cache FILE, not --cache-dir. Use --base-cache DIR for a common root.')
if tool == 'tickets' and '--cache' in options:
raise ConfigError('Tickets uses --cache-dir DIR, not --cache. Use REDMINE_CONTACTS_CACHE for the full CRM handoff.')
mode = options.get('--mode', lane)
mode = {'production':'live','prod':'live','development':'dev'}.get(mode, mode)
if mode not in {'live','dev'}:
raise ConfigError('--mode must be production/live or dev.')
home = Path(env.get('HOME', str(Path.home())))
candidates = ([ROOT/'.env.dev', ROOT/'bin/.env.dev', home/'.redmine.dev'] if mode == 'dev'
else [ROOT/'.env', ROOT/'bin/.env', home/'.redmine.live'])
explicit = options.get('--env-file') or env.get('REDMINE_ENV_FILE')
selected = absolute(explicit) if explicit else next((p for p in candidates if p.is_file()), None)
data = env_values(selected) if selected else {}
sources = {}
config = {}
config_path = None
if '--config' in options:
if tool != 'tickets':
raise ConfigError('--config is a tickets option; use --env-file for contacts.')
config_path = absolute(options['--config'])
try:
text = config_path.read_text(encoding='utf-8')
try:
raw = json.loads(text)
except json.JSONDecodeError:
try:
import yaml
except ImportError as exc:
raise ConfigError('YAML config needs existing PyYAML; use JSON instead.') from exc
raw = yaml.safe_load(text)
if not isinstance(raw, dict):
raise ConfigError(f'Config must be an object: {config_path}')
raw = raw.get('redmine_tickets', raw)
if not isinstance(raw, dict):
raise ConfigError(f'redmine_tickets must be an object: {config_path}')
config = {str(k).replace('_','-'):v for k,v in raw.items()}
except OSError as exc:
raise ConfigError(f'Cannot read config {config_path}: {exc.strerror}') from exc
except ValueError as exc:
raise ConfigError(f'Invalid config file: {config_path}') from exc
def pick(name, flag, keys, default=None, cfg=None):
if flag and flag in options:
sources[name] = f'CLI {flag}'
return options[flag]
for mapping, source in [(env,'environment'), (data,f'env file {selected}')]:
for key in keys:
if key in mapping:
sources[name] = f'{source}: {key}'
return mapping[key]
if cfg and cfg in config:
sources[name] = f'config {config_path}: {cfg}'
return config[cfg]
sources[name] = 'default'
return default
def path_value(name, value):
if value is None or str(value).strip() == '':
raise ConfigError(f'{name} cannot be empty; source: {sources.get(name)}')
src = sources.get(name, '')
base = selected.parent if src.startswith('env file ') else (config_path.parent if src.startswith('config ') else None)
return absolute(value, base)
url = str(pick('base_url','--base-url',['REDMINE_BASE_URL','REDMINE_ENDPOINT'],
'https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170', 'base-url')).rstrip('/')
parsed = urlsplit(url)
if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigError('REDMINE_BASE_URL/--base-url must be an http(s) URL without credentials/query/fragment.')
project = str(pick('project','--project',['REDMINE_PROJECT'],'customer-service','project'))
if not project.strip() or project in {'.','..'} or '/' in project or '\\' in project:
raise ConfigError('REDMINE_PROJECT/--project must be a project identifier, not a path.')
base = path_value('base_cache', pick('base_cache','--base-cache',['REDMINE_BASE_CACHE'],ROOT/('cache-dev' if mode == 'dev' else 'cache')))
slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+','_',s)
host_project = slug(parsed.netloc + parsed.path) + '_' + slug(project)
is_default = project == 'customer-service' and url == ('https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170')
ticket_default = base / ('redmine_tickets' if is_default else 'redmine_tickets_' + slug(parsed.scheme + '_' + host_project))
tickets = path_value('ticket_cache', pick('ticket_cache','--cache-dir',['REDMINE_TICKETS_CACHE','REDMINE_CACHE_DIR'],ticket_default,'cache-dir'))
# Preserve the historical single-file snapshot when it already exists.
# Fresh installs get a namespaced file under redmine_contacts/ so different
# hosts/projects never collide. This is compatibility, not a migration.
legacy_contacts = base / 'redmine_contacts'
contacts_default = legacy_contacts if legacy_contacts.is_file() and is_default else legacy_contacts / (host_project+'.json')
contacts = path_value('contacts_cache', pick('contacts_cache','--cache',['REDMINE_CONTACTS_CACHE'],contacts_default))
if contacts.is_dir():
raise ConfigError(f'Contacts --cache expects a JSON file, not a directory: {contacts}')
key = pick('api_key',None,['REDMINE_API_KEY'])
key_file = pick('api_key_file','--api-key-file',['REDMINE_API_KEY_FILE'],None,'api-key-file')
# Explicit key-file is a CLI credential override. Config key files are fallback.
if key_file and ('--api-key-file' in options or not key):
key_path = path_value('api_key_file',key_file)
try:
key = key_path.read_text(encoding='utf-8').strip()
except OSError as exc:
raise ConfigError(f'Cannot read API key file {key_path}: {exc.strerror}') from exc
sources['api_key'] = f'key file {key_path}'
key = str(key or '').strip()
child_env = dict(env)
for k,v in data.items():
child_env.setdefault(k,v)
child_env.update(REDMINE_BASE_URL=url, REDMINE_PROJECT=project, REDMINE_BASE_CACHE=str(base),
REDMINE_CONTACTS_CACHE=str(contacts), REDMINE_TICKETS_CACHE=str(tickets), REDMINE_API_KEY=key)
if selected:
child_env['REDMINE_ENV_FILE'] = str(selected)
# Relative bridge files in env profiles are relative to that profile.
for k in ['NMI_ENV_FILE','NMI_CLI']:
if k not in env and data.get(k) and (k == 'NMI_ENV_FILE' or '/' in data[k]):
child_env[k] = str(absolute(data[k],selected.parent))
child_env.setdefault('NMI_MODE','production' if mode == 'live' else 'dev')
report = {'mode':mode,'base_url':url,'project':project,'base_cache':str(base),
'ticket_cache':str(tickets),'contacts_cache':str(contacts),
'ticket_contacts_cache':str(tickets/'contacts'),
'contacts_cache_layout':'legacy-single-file' if contacts == legacy_contacts else 'namespaced-file',
'cache_note':'Full CRM snapshot and ticket contacts mirror are different formats; existing legacy snapshot is preserved; no migration.',
'env_file':str(selected) if selected else None,
'searched_env_paths':[str(selected)] if explicit else [str(p) for p in candidates],
'config_file':str(config_path) if config_path else None,
'api_key_present':bool(key),'sources':sources,
'nmi_mode':child_env['NMI_MODE'], 'lock_timeout':child_env.get('REDMINE_LOCK_TIMEOUT','60')}
injected = ['--base-url',url,'--project',project, '--cache' if tool=='contacts' else '--cache-dir',str(contacts if tool=='contacts' else tickets)]
if tool == 'tickets' and config.get('allow-prod-writes') in (True,'true','yes',1,'1'):
injected.append('--allow-prod-writes')
return report, child_env, injected
HELP = '''Wrapper configuration (same for rcontacts-live, rtickets-live / rtl, rtd):
--env-file PATH Explicit NAME=value file; REDMINE_ENV_FILE also works
--base-cache DIR Common root; REDMINE_BASE_CACHE (default checkout/cache)
--show-config Redacted effective configuration and source paths (offline)
--doctor Validate local config, key presence and cache access (offline)
--doctor-nmi Run the actual NMI CLI's offline doctor
--mode dev Explicit development; rtd defaults to this
Precedence: CLI > exported environment > selected env file > ticket config > defaults.
Live env search: checkout/.env, checkout/bin/.env, ~/.redmine.live (first existing).
Dev env search: checkout/.env.dev, checkout/bin/.env.dev, ~/.redmine.dev.
Required online: REDMINE_API_KEY (or tickets --api-key-file).
Optional: REDMINE_BASE_URL, REDMINE_PROJECT, REDMINE_BASE_CACHE,
REDMINE_CONTACTS_CACHE (JSON file), REDMINE_TICKETS_CACHE (directory).
NMI: NMI_CLI, NMI_ENV_FILE, NMI_MODE; use --doctor-nmi for required credential names.
Exact --cache (contacts file) / --cache-dir (tickets directory) override defaults.
Relative CLI/exported paths use cwd; file-defined paths use that file's directory.
Existing apply/customer-email/production-write safety gates are unchanged.
'''
def contacts_handoff(args, contact_id):
"""Copy/paste-safe bridge to the full CRM, without putting secrets in argv."""
url = str(args.base_url)
project = str(args.project)
env_file = os.environ.get('REDMINE_ENV_FILE')
cache = os.environ.get('REDMINE_CONTACTS_CACHE')
if not cache:
parsed = urlsplit(url)
slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+', '_', s)
base = Path(os.environ.get('REDMINE_BASE_CACHE') or Path(args.cache_dir).parent)
cache = str(base / 'redmine_contacts' / (slug(parsed.netloc + parsed.path) + '_' + slug(project) + '.json'))
cmd = [str(ROOT / 'bin/rcontacts-live')]
if env_file:
cmd.extend(['--env-file', env_file])
if getattr(args, 'api_key_file', None):
cmd.extend(['--api-key-file', str(absolute(args.api_key_file))])
cmd.extend(['--base-url', url, '--project', project, '--cache', str(absolute(cache)), 'show', '--contact-id', str(contact_id)])
return shlex.join(cmd)
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
tool, lane, *args = argv
script = ROOT / ('redmine_contacts.py' if tool == 'contacts' else 'redmine_tickets.py')
try:
opts, rest = split_options(args)
if not args or '--help' in rest or '-h' in rest:
if not args or rest in (['--help'],['-h']):
print(HELP, flush=True)
os.execv(sys.executable,[sys.executable,str(script),*(rest or ['--help'])])
report, child_env, injected = resolve(tool,lane,opts)
if report['mode'] == 'dev':
print(f"DEVELOPMENT runtime: {report['base_url']} (NMI_MODE={report['nmi_mode']})",file=sys.stderr)
if opts.get('--doctor-nmi'):
from redmine_ticket_nmi import discover_nmi_bin
import shutil
php = shutil.which('php',path=child_env.get('PATH'))
if not php:
raise ConfigError('PHP CLI is not on PATH; required by NMI.')
binary = discover_nmi_bin(environ=child_env)
if not binary.is_file():
raise ConfigError(f'NMI CLI not found: {binary}; set NMI_CLI.')
try:
proc = subprocess.run([php,str(binary),'--doctor','--json'],env=child_env,timeout=15)
except subprocess.TimeoutExpired as exc:
raise ConfigError('NMI offline doctor timed out after 15 seconds.') from exc
return proc.returncode
if opts.get('--show-config') or opts.get('--doctor'):
problems = []
if not report['api_key_present']:
problems.append('REDMINE_API_KEY is missing/empty; set it in the environment or a listed env file (or use --api-key-file).')
import math
try:
seconds = float(report['lock_timeout'])
if not math.isfinite(seconds) or seconds < 0:
raise ValueError()
except ValueError:
problems.append('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.')
for target in [Path(report['ticket_cache']),Path(report['contacts_cache']).parent]:
existing=target
while not existing.exists() and existing != existing.parent:
existing=existing.parent
if not existing.is_dir() or not os.access(existing,os.W_OK|os.X_OK):
problems.append(f'Cache parent is not writable: {existing}')
report.update(ready=not problems,problems=problems,network_checked=False)
if selected:
report['env_file_loaded'] = str(selected)
report['env_file_data_keys'] = list(data.keys()) if data else []
print(json.dumps(report,indent=2))
return 2 if opts.get('--doctor') and problems else 0
if not report['api_key_present']:
local = {'search','view','walk','browse','tui','textual-tui','contacts-search','ticket-contact','control-tag','draft','materialize-queues','saved-filters','entities'}
command = next((a for a in rest if not a.startswith('-')), '')
cache_only = (tool=='contacts' and ('--offline-cache' in rest or '--source=cache' in rest or ['--source','cache']==rest[-2:])) or (tool=='tickets' and command in local)
if not cache_only:
paths = ', '.join(report['searched_env_paths'])
raise ConfigError(f'REDMINE_API_KEY is missing/empty. Checked environment and {paths}. Set REDMINE_API_KEY, --env-file, or --api-key-file; run --doctor.')
os.execve(sys.executable,[sys.executable,str(script),*injected,*rest],child_env)
except (ConfigError,OSError) as exc:
print(f'Configuration error: {exc}',file=sys.stderr)
return 2
if __name__=='__main__':
raise SystemExit(main())
+22 -4
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
from functools import wraps
from redmine_cache_io import CacheError, cache_lock, atomic_write_json
import os import os
import re import re
import subprocess import subprocess
@@ -497,6 +499,7 @@ def main() -> int:
try: try:
if args.command == "fetch": if args.command == "fetch":
with cache_lock(resolve_cache_path(args)):
client = client_from_args(args) client = client_from_args(args)
contacts = client.fetch_contacts(limit=args.limit) contacts = client.fetch_contacts(limit=args.limit)
filtered_contacts = filter_contacts_from_args(contacts, args) filtered_contacts = filter_contacts_from_args(contacts, args)
@@ -627,7 +630,7 @@ def main() -> int:
client = client_from_args(args) client = client_from_args(args)
print_json(client.helpdesk_timeline(args.contact_id, args.limit)) print_json(client.helpdesk_timeline(args.contact_id, args.limit))
return 0 return 0
except RedmineError as exc: except (RedmineError, CacheError) as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
@@ -683,6 +686,21 @@ def add_contact_filter_args(parser: argparse.ArgumentParser) -> None:
) )
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]]: def load_or_refresh(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args) cache_path = resolve_cache_path(args)
if args.refresh or not cache_path.exists(): if args.refresh or not cache_path.exists():
@@ -986,6 +1004,7 @@ def read_cache_payload(cache_path: Path, args: argparse.Namespace) -> dict[str,
return data return data
@snapshot_transaction
def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]: def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]:
source = args.source source = args.source
cache_path = resolve_cache_path(args) cache_path = resolve_cache_path(args)
@@ -1026,6 +1045,7 @@ def has_update_changes(args: argparse.Namespace) -> bool:
) )
@snapshot_transaction
def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]: def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args) cache_path = resolve_cache_path(args)
@@ -1304,9 +1324,7 @@ def write_cache(cache_path: Path, args: argparse.Namespace, contacts: list[dict[
"cached_at": int(time.time()), "cached_at": int(time.time()),
"contacts": contacts, "contacts": contacts,
} }
with cache_path.open("w", encoding="utf-8") as handle: atomic_write_json(cache_path, payload)
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
def resolve_cache_path(args: argparse.Namespace) -> Path: def resolve_cache_path(args: argparse.Namespace) -> Path:
+5 -10
View File
@@ -12,6 +12,7 @@ enough for sync + local search + show next to tickets.
from __future__ import annotations from __future__ import annotations
import json import json
from redmine_cache_io import cache_lock, read_json, atomic_write_json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@@ -34,12 +35,7 @@ class ContactCache:
def load_manifest(self) -> dict[str, Any]: def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file(): if not self.manifest_path.is_file():
return {"version": 1, "contacts": {}, "updated_at": None} return {"version": 1, "contacts": {}, "updated_at": None}
try: data = read_json(self.manifest_path)
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"version": 1, "contacts": {}, "updated_at": None}
if not isinstance(data, dict):
return {"version": 1, "contacts": {}, "updated_at": None}
data.setdefault("version", 1) data.setdefault("version", 1)
data.setdefault("contacts", {}) data.setdefault("contacts", {})
return data return data
@@ -48,9 +44,7 @@ class ContactCache:
data = dict(data) data = dict(data)
data["version"] = 1 data["version"] = 1
data["updated_at"] = now_iso() data["updated_at"] = now_iso()
tmp = self.manifest_path.with_suffix(".tmp") atomic_write_json(self.manifest_path, data)
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(self.manifest_path)
def should_fetch(self, stub: dict[str, Any]) -> bool: def should_fetch(self, stub: dict[str, Any]) -> bool:
cid = stub.get("id") cid = stub.get("id")
@@ -64,11 +58,12 @@ class ContactCache:
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "") return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
def write_contact(self, contact: dict[str, Any]) -> Path: def write_contact(self, contact: dict[str, Any]) -> Path:
with cache_lock(self.manifest_path):
cid = contact.get("id") cid = contact.get("id")
if cid is None: if cid is None:
raise ValueError("contact missing id") raise ValueError("contact missing id")
path = self.contact_path(int(cid)) path = self.contact_path(int(cid))
path.write_text(json.dumps(contact, indent=2, sort_keys=True) + "\n", encoding="utf-8") atomic_write_json(path, contact)
manifest = self.load_manifest() manifest = self.load_manifest()
contacts = manifest.setdefault("contacts", {}) contacts = manifest.setdefault("contacts", {})
contacts[str(int(cid))] = { contacts[str(int(cid))] = {
+168 -156
View File
@@ -16,16 +16,21 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
# Prefer env overrides for agents/profiles # Compatibility constants are environment-independent. Resolve overrides at call
DEFAULT_NMI_BIN = os.environ.get( # time so a TUI/worker cannot retain a profile captured during module import.
"NMI_CLI", DEFAULT_NMI_BIN = str(Path(__file__).resolve().parent.parent / "mrscClient-php" / "bin" / "nmi")
"/home/jason/projects/mrscClient-php/bin/nmi", DEFAULT_ENV_FILE = None # PHP owns default env-file discovery.
) DEFAULT_MODE = "production"
DEFAULT_ENV_FILE = os.environ.get(
"NMI_ENV_FILE",
"/home/jason/projects/mrscClient-php/examples/modern/.env", def discover_nmi_bin(value: Optional[str] = None, *, environ=None) -> Path:
) env = os.environ if environ is None else environ
DEFAULT_MODE = os.environ.get("NMI_MODE", "dev") selected = value or env.get("NMI_CLI")
if selected:
expanded = str(Path(selected).expanduser())
return Path(shutil.which(expanded, path=env.get("PATH", os.defpath)) or expanded).resolve()
found = shutil.which("nmi", path=env.get("PATH", os.defpath))
return Path(found or DEFAULT_NMI_BIN).resolve()
# Entity types that map cleanly into NMI resolve ids # Entity types that map cleanly into NMI resolve ids
_TRACKING_TYPES = { _TRACKING_TYPES = {
@@ -64,7 +69,7 @@ class NmiRuntimeProfile:
"""Resolved NMI execution settings plus a preflight result.""" """Resolved NMI execution settings plus a preflight result."""
mode: str mode: str
env_file: Path env_file: Optional[Path]
nmi_bin: Path nmi_bin: Path
endpoint: Optional[str] endpoint: Optional[str]
ready: bool ready: bool
@@ -79,61 +84,65 @@ def resolve_runtime_profile(
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
environ: Optional[dict[str, str]] = None, environ: Optional[dict[str, str]] = None,
) -> NmiRuntimeProfile: ) -> NmiRuntimeProfile:
"""Resolve the exact profile passed to PHP before an NMI subprocess starts. """Run the actual PHP resolver offline; never equate file existence to readiness.
An env file that is empty or missing is always rejected. It is too easy for With no explicit env file, let NMI perform its own documented discovery.
the checked-in example `.env` to otherwise select a surprising dev/default Environment-only credentials are valid. No service requests are performed.
path while making the TUI look successful.
""" """
env = dict(os.environ if environ is None else environ)
env = os.environ if environ is None else environ
requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower() requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower()
normalized_mode = "production" if requested_mode in {"prod", "production"} else requested_mode normalized_mode = "production" if requested_mode == "prod" else requested_mode
selected_env_file = Path(env_file or env.get("NMI_ENV_FILE") or DEFAULT_ENV_FILE).expanduser() requested_file = env_file or env.get("NMI_ENV_FILE")
selected_nmi_bin = Path(nmi_bin or env.get("NMI_CLI") or DEFAULT_NMI_BIN).expanduser() selected_env_file = Path(requested_file).expanduser().resolve() if requested_file else None
selected_nmi_bin = discover_nmi_bin(nmi_bin, environ=env)
mode_upper = normalized_mode.upper() def failed(problem):
selected_endpoint = ( return NmiRuntimeProfile(normalized_mode, selected_env_file, selected_nmi_bin, endpoint, False, problem)
endpoint
or env.get(f"NMI_ENDPOINT_{mode_upper}")
or env.get("NMI_ENDPOINT")
or None
)
if normalized_mode not in {"production", "dev"}:
return failed("NMI_MODE must be production or dev.")
if selected_env_file is not None:
try:
if not selected_env_file.is_file(): if not selected_env_file.is_file():
return NmiRuntimeProfile( return failed(f"NMI env file does not exist: {selected_env_file}")
mode=normalized_mode,
env_file=selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=False,
problem=f"NMI env file does not exist: {selected_env_file}",
)
if selected_env_file.stat().st_size == 0: if selected_env_file.stat().st_size == 0:
return NmiRuntimeProfile( return failed(f"NMI env file is empty: {selected_env_file}")
mode=normalized_mode, except OSError as exc:
env_file=selected_env_file, return failed(f"Cannot inspect NMI env file: {exc}")
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=False,
problem=f"NMI env file is empty: {selected_env_file}",
)
if not selected_nmi_bin.is_file(): if not selected_nmi_bin.is_file():
return failed(f"NMI CLI does not exist: {selected_nmi_bin}; set NMI_CLI.")
php = shutil.which("php", path=env.get("PATH", os.defpath))
if not php:
return failed("PHP is not on PATH; NMI requires the php CLI.")
cmd = [php, str(selected_nmi_bin), "--doctor", "--json"]
# Preserve NMI env-file mode when no caller/environment mode was supplied.
if mode or env.get("NMI_MODE"):
cmd.append(f"--mode={normalized_mode}")
if selected_env_file:
cmd.append(f"--env-file={selected_env_file}")
if endpoint:
cmd.append(f"--endpoint={endpoint}")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False, env=env)
except subprocess.TimeoutExpired:
return failed("NMI offline doctor timed out after 15 seconds.")
except OSError as exc:
return failed(f"Could not start NMI doctor: {exc}")
try:
data = json.loads(proc.stdout)
if not isinstance(data, dict):
raise ValueError("expected an object")
except (ValueError, TypeError):
return failed(f"NMI doctor returned invalid diagnostic JSON (exit {proc.returncode}); update NMI_CLI to a compatible bin/nmi.")
ready = proc.returncode == 0 and data.get("ready") is True
resolved_file = data.get("env_file")
return NmiRuntimeProfile( return NmiRuntimeProfile(
mode=normalized_mode, mode=data.get("mode") or normalized_mode,
env_file=selected_env_file, env_file=Path(resolved_file) if resolved_file else selected_env_file,
nmi_bin=selected_nmi_bin, nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint, endpoint=data.get("endpoint") or endpoint,
ready=False, ready=ready,
problem=f"NMI CLI does not exist: {selected_nmi_bin}", problem=None if ready else (data.get("problem") or f"NMI doctor failed (exit {proc.returncode})."),
)
return NmiRuntimeProfile(
mode=normalized_mode,
env_file=selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=True,
) )
@@ -223,30 +232,35 @@ def _php_bin() -> str:
def run_nmi( def run_nmi(
args: list[str], args: list[str],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
timeout: int = 60, timeout: int = 60,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
cmd = [_php_bin(), nmi_bin, *args, f"--mode={mode}", f"--env-file={env_file}"] cmd = [_php_bin(), str(discover_nmi_bin(nmi_bin)), *args]
selected_mode = mode or os.environ.get("NMI_MODE")
selected_file = env_file or os.environ.get("NMI_ENV_FILE")
if selected_mode:
cmd.append(f"--mode={selected_mode}")
if selected_file:
cmd.append(f"--env-file={Path(selected_file).expanduser().resolve()}")
if endpoint: if endpoint:
cmd.append(f"--endpoint={endpoint}") cmd.append(f"--endpoint={endpoint}")
return subprocess.run( try:
cmd, return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
capture_output=True, except subprocess.TimeoutExpired:
text=True, return subprocess.CompletedProcess(cmd, 124, "", f"NMI command timed out after {timeout} seconds.")
timeout=timeout, except OSError as exc:
check=False, return subprocess.CompletedProcess(cmd, 127, "", f"Could not start NMI CLI: {exc}")
)
def try_resolve_ids( def try_resolve_ids(
plan: NmiLookupPlan, plan: NmiLookupPlan,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
include_pictures: bool = False, include_pictures: bool = False,
max_attempts: int = 6, max_attempts: int = 6,
@@ -294,9 +308,9 @@ def try_resolve_ids(
def try_cs_brief( def try_cs_brief(
plan: NmiLookupPlan, plan: NmiLookupPlan,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
max_attempts: int = 6, max_attempts: int = 6,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -336,9 +350,9 @@ def brief_for_issue(
issue: dict[str, Any], issue: dict[str, Any],
entities: list[dict[str, str]], entities: list[dict[str, str]],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
with_history: bool = True, with_history: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -350,7 +364,7 @@ def brief_for_issue(
) )
runtime = { runtime = {
"mode": profile.mode, "mode": profile.mode,
"env_file": str(profile.env_file), "env_file": str(profile.env_file) if profile.env_file else None,
"nmi_bin": str(profile.nmi_bin), "nmi_bin": str(profile.nmi_bin),
"endpoint": profile.endpoint, "endpoint": profile.endpoint,
"ready": profile.ready, "ready": profile.ready,
@@ -400,6 +414,11 @@ def brief_for_issue(
out["error"] = "no_nmi_candidates" out["error"] = "no_nmi_candidates"
return out return out
# Execute exactly the profile checked above, not import-time/caller defaults.
mode = profile.mode
env_file = str(profile.env_file) if profile.env_file else None
nmi_bin = str(profile.nmi_bin)
endpoint = profile.endpoint
brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint) brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
out["brief"] = brief out["brief"] = brief
out["ok"] = bool(brief.get("ok")) out["ok"] = bool(brief.get("ok"))
@@ -416,69 +435,78 @@ def brief_for_issue(
def format_private_note(payload: dict[str, Any]) -> str: def format_private_note(payload: dict[str, Any]) -> str:
"""Build a Redmine private note body from nmi brief result. """Build a concise, actionable Redmine private note from NMI data.
Enhanced with: Focuses on the fields operators actually need:
- Deep NMI links (Item Manager, Box History, Request pages etc.) per the WIP note - Clear identifiers (order_id / request_id)
- Item history from the new getItemHistory endpoint (billings, who moved, condition changes) - Customer notes and item details
- Gist + next tangible event
- Likely escalations and resolution options
- Skeptic analysis for known problematic patterns
Raw verbose output is no longer dumped by default.
""" """
lines = [ lines = [
"=== Auto NMI pre-fetch (agent) ===", "=== Auto NMI pre-fetch (agent) ===",
f"issue_id: {payload.get('issue_id')}", f"issue_id: {payload.get('issue_id')}",
] ]
plan = payload.get("plan") or {}
lines.append(
"candidates: "
+ json.dumps(
{
"tracking": plan.get("tracking"),
"order_id": plan.get("order_id"),
"item_no": plan.get("item_no"),
"account": plan.get("account"),
},
sort_keys=True,
)
)
# Deep links from the WIP note plan = payload.get("plan") or {}
order_ids = plan.get("order_id") or []
item_nos = plan.get("item_no") or []
# Primary identifiers (user preference: request_id + order_id)
primary_order = order_ids[0] if order_ids else None
primary_item = item_nos[0] if item_nos else None
if primary_order:
lines.append(f"order_id: {primary_order}")
if primary_item:
lines.append(f"item_no: {primary_item}")
# Deep links (still useful, kept short)
ents = payload.get("entities") or [] ents = payload.get("entities") or []
links = generate_nmi_links(ents) links = generate_nmi_links(ents)
if links: if links:
lines.append("") lines.append("")
lines.append("=== NMI Deep Links (Item Manager / Box History / Request pages / userInfo) ===") lines.append("=== NMI Deep Links ===")
lines.extend(links[:12]) lines.extend(links[:6])
brief = payload.get("brief") or {} brief = payload.get("brief") or {}
if brief.get("ok") and brief.get("text"): brief_text = brief.get("text", "") if brief.get("ok") else ""
lines.append("")
lines.append(brief["text"].rstrip())
else:
lines.append("")
lines.append("NMI lookup did not resolve cleanly.")
if payload.get("error"):
lines.append(f"error: {payload['error']}")
attempts = brief.get("attempts") or (payload.get("resolve") or {}).get("attempts") or []
for a in attempts[:5]:
lines.append(f"- attempt {a.get('args')}: rc={a.get('returncode')} err={a.get('stderr') or a.get('error')}")
# New history # Extract key fields from cs-brief if present (lightweight parsing)
hist = payload.get("item_history") or {} customer_notes = ""
if hist.get("ok"): items_section = []
lines.append("") if brief_text:
lines.append("=== Item History (getItemHistory) ===") # Very lightweight extraction — look for common patterns in cs-brief output
h = hist.get("history") or {} for line in brief_text.splitlines():
if isinstance(h, dict) and h: l = line.strip()
lines.append(json.dumps(h, indent=2, default=str)[:1600]) if l.lower().startswith("customer") and "note" in l.lower():
elif hist.get("text"): customer_notes = l
lines.append(hist["text"][:1400]) if l.lower().startswith(("merchant_sku", "product_name", "item")):
else: items_section.append(l)
lines.append("(see raw for full history)")
elif "item_history" in payload:
lines.append("")
lines.append("Item history note: " + str((payload.get("item_history") or {}).get("error", "no data")))
if customer_notes:
lines.append("")
lines.append(f"customer_notes: {customer_notes}")
if items_section:
lines.append("")
lines.append("Item(s):")
for item in items_section[:4]:
lines.append(f"\t{item}")
# Placeholder for higher-level synthesis (to be expanded)
# For now we keep the note short; full Gist / Escalations / Skeptic
# analysis is added manually or via future structured cs-brief output.
lines.append("")
lines.append("Gist: (see ticket journals for context)")
lines.append("Next tangible event: receive the delivery")
lines.append("") lines.append("")
lines.append("_Generated by redmine_ticket_nmi; verify before customer-facing use._") lines.append("_Generated by redmine_ticket_nmi; verify before customer-facing use._")
lines.append("_Note: This is a concise view. Run with --verbose for full cs-brief if needed._")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]: def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]:
@@ -504,33 +532,20 @@ def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]:
def try_item_history( def try_item_history(
item_no: str, item_no: str,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
limit: int = 50, limit: int = 50,
snapshot: bool = True, snapshot: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call nmi item history for a single item_no. Returns structured result.""" """Call nmi item history for a single item_no. Returns structured result."""
cmd = [ args = ["item", "history", f"--item-no={item_no}", f"--limit={limit}"]
"php",
nmi_bin,
"item",
"history",
f"--item-no={item_no}",
f"--limit={limit}",
]
if snapshot: if snapshot:
cmd.append("--snapshot") args.append("--snapshot")
if mode:
cmd.extend(["--mode", mode])
if env_file:
cmd.extend(["--env-file", env_file])
if endpoint:
cmd.extend(["--endpoint", endpoint])
try: try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30) res = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint, timeout=30)
cmd = res.args
if res.returncode != 0: if res.returncode != 0:
return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd} return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd}
# Try JSON first # Try JSON first
@@ -546,9 +561,9 @@ def try_item_history(
def enrich_with_history( def enrich_with_history(
payload: dict[str, Any], payload: dict[str, Any],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""If an item_no is present in the plan, fetch its history and attach.""" """If an item_no is present in the plan, fetch its history and attach."""
@@ -557,7 +572,7 @@ def enrich_with_history(
if not item_no: if not item_no:
return payload return payload
hist = try_item_history( hist = try_item_history(
str(item_no), str(item_no[0] if isinstance(item_no, list) else item_no),
mode=mode, mode=mode,
env_file=env_file, env_file=env_file,
nmi_bin=nmi_bin, nmi_bin=nmi_bin,
@@ -574,9 +589,9 @@ def try_auto_link_redmine_contact(
tracking: list[str] | None = None, tracking: list[str] | None = None,
order_id: list[str] | None = None, order_id: list[str] | None = None,
item_no: list[str] | None = None, item_no: list[str] | None = None,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI """If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI
and automatically create the redmine_contact_links entry. and automatically create the redmine_contact_links entry.
@@ -602,13 +617,10 @@ def try_auto_link_redmine_contact(
if not ids: if not ids:
return {"ok": False, "reason": "no_identifiers"} return {"ok": False, "reason": "no_identifiers"}
cmd = [nmi_bin, "link-contact", "--redmine-contact-id", str(contact_id), "--mode", mode] args = ["link-contact", "--redmine-contact-id", str(contact_id), *ids]
if env_file and env_file != DEFAULT_ENV_FILE:
cmd.extend(["--env-file", env_file])
cmd.extend(ids)
try: try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90) proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, timeout=90)
cmd = proc.args
ok = proc.returncode == 0 ok = proc.returncode == 0
result = { result = {
"ok": ok, "ok": ok,
+2 -2
View File
@@ -989,8 +989,8 @@ class RedmineTextualTUI(App):
except Exception: except Exception:
pass pass
mode = os.environ.get("NMI_MODE", "dev") mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" env_file = os.environ.get("NMI_ENV_FILE")
if ch.entity_extract and issue and extract_entities_from_issue: if ch.entity_extract and issue and extract_entities_from_issue:
try: try:
+4 -4
View File
@@ -442,16 +442,16 @@ class RedmineTUI:
except Exception: except Exception:
pass pass
mode = os.environ.get("NMI_MODE", "dev") mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" env_file = os.environ.get("NMI_ENV_FILE")
runtime_profile = ( runtime_profile = (
resolve_runtime_profile(mode=mode, env_file=env_file) resolve_runtime_profile(mode=mode, env_file=env_file)
if resolve_runtime_profile is not None if ch.nmi_brief and resolve_runtime_profile is not None
else None else None
) )
if runtime_profile is not None: if runtime_profile is not None:
mode = runtime_profile.mode mode = runtime_profile.mode
env_file = str(runtime_profile.env_file) env_file = str(runtime_profile.env_file) if runtime_profile.env_file else None
if ch.entity_extract and issue_for_special and extract_entities_from_issue: if ch.entity_extract and issue_for_special and extract_entities_from_issue:
try: try:
+61 -20
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
import copy
from redmine_cache_io import CacheError, cache_lock, read_json, atomic_write_json, update_manifest_atomic
import os import os
import re import re
import sys import sys
@@ -1062,26 +1064,45 @@ class ManifestTicketStateStore:
self.project = project self.project = project
self.manifest_path = cache_dir / "manifest.json" self.manifest_path = cache_dir / "manifest.json"
self.manifest = self.load_manifest() self.manifest = self.load_manifest()
self._baseline = copy.deepcopy(self.manifest)
def load_manifest(self) -> dict[str, Any]: def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file(): if not self.manifest_path.is_file():
return {"version": 1, "tickets": {}} return {"version": 1, "tickets": {}}
with self.manifest_path.open("r", encoding="utf-8") as handle: data = read_json(self.manifest_path)
data = json.load(handle)
data.setdefault("version", 1) data.setdefault("version", 1)
data.setdefault("tickets", {}) data.setdefault("tickets", {})
return data return data
def save(self) -> None: def save(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True) # Apply only locally changed leaves to current disk state. This preserves
with self.manifest_path.open("w", encoding="utf-8") as handle: # updates made by another long-lived cache instance since our last read.
json.dump(self.manifest, handle, indent=2, sort_keys=True) def delta(current, before, after):
handle.write("\n") for key in before.keys() - after.keys():
current.pop(key, None)
for key, value in after.items():
if key not in before:
current[key] = copy.deepcopy(value)
elif isinstance(value, dict) and isinstance(before[key], dict):
target = current.setdefault(key, {})
if not isinstance(target, dict):
target = current[key] = {}
delta(target, before[key], value)
elif value != before[key]:
current[key] = copy.deepcopy(value)
self.manifest = update_manifest_atomic(
self.manifest_path, lambda current: delta(current, self._baseline, self.manifest)
)
self._baseline = copy.deepcopy(self.manifest)
def get_ticket_state(self, issue_id: int | str) -> dict[str, Any] | None: def get_ticket_state(self, issue_id: int | str) -> dict[str, Any] | None:
return self.manifest.get("tickets", {}).get(str(issue_id)) # Readers see atomic snapshots and need no writer lock.
return self.load_manifest().get("tickets", {}).get(str(issue_id))
def upsert_ticket_state(self, issue_id: int | str, fields: dict[str, Any]) -> None: def upsert_ticket_state(self, issue_id: int | str, fields: dict[str, Any]) -> None:
with cache_lock(self.manifest_path):
self.manifest = self.load_manifest()
self._baseline = copy.deepcopy(self.manifest)
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
ticket_entry.setdefault("project", self.project) ticket_entry.setdefault("project", self.project)
ticket_entry.setdefault("actions", {}) ticket_entry.setdefault("actions", {})
@@ -1099,6 +1120,9 @@ class ManifestTicketStateStore:
result: str, result: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
with cache_lock(self.manifest_path):
self.manifest = self.load_manifest()
self._baseline = copy.deepcopy(self.manifest)
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
ticket_entry.setdefault("project", self.project) ticket_entry.setdefault("project", self.project)
actions = ticket_entry.setdefault("actions", {}) actions = ticket_entry.setdefault("actions", {})
@@ -1146,10 +1170,11 @@ class TicketCache:
def write_issue(self, issue: dict[str, Any]) -> None: def write_issue(self, issue: dict[str, Any]) -> None:
issue_id = issue["id"] issue_id = issue["id"]
self.project_dir.mkdir(parents=True, exist_ok=True) # Use one lock for a record + manifest transaction. Alternate stores keep
with self.issue_path(issue_id).open("w", encoding="utf-8") as handle: # their own state transaction semantics, but JSON still writes atomically.
json.dump(issue, handle, indent=2, sort_keys=True) lock_target = self.manifest_path or (self.cache_dir / "manifest.json")
handle.write("\n") with cache_lock(lock_target):
atomic_write_json(self.issue_path(issue_id), issue)
self.state_store.upsert_ticket_state( self.state_store.upsert_ticket_state(
issue_id, issue_id,
{ {
@@ -2322,10 +2347,14 @@ def run_walk_session(issues: list[dict[str, Any]], cache: TicketCache, pager: st
def client_from_args(args: argparse.Namespace) -> TicketRedmineClient: def client_from_args(args: argparse.Namespace) -> TicketRedmineClient:
api_key = os.getenv("REDMINE_API_KEY") api_key = os.getenv("REDMINE_API_KEY")
if not api_key and getattr(args, "api_key_file", None): if getattr(args, "api_key_file", None):
key_path = Path(args.api_key_file) key_path = Path(args.api_key_file).expanduser()
if key_path.is_file(): try:
api_key = key_path.read_text(encoding="utf-8").strip() api_key = key_path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise RedmineError(f"Cannot read API key file {key_path}: {exc.strerror}") from exc
# The internal contacts client and attachment/OCR helpers share this key.
os.environ["REDMINE_API_KEY"] = api_key
if not api_key: if not api_key:
raise RedmineError("Set REDMINE_API_KEY in the environment or supply --api-key-file.") raise RedmineError("Set REDMINE_API_KEY in the environment or supply --api-key-file.")
return TicketRedmineClient(base_url=args.base_url, api_key=api_key, project=args.project) return TicketRedmineClient(base_url=args.base_url, api_key=api_key, project=args.project)
@@ -3265,7 +3294,7 @@ KNOWN_COMMANDS = frozenset(
# Parent-parser options that take a following value. Used when injecting the # Parent-parser options that take a following value. Used when injecting the
# default `sync` subcommand so sync-only flags like --updated-since parse correctly. # default `sync` subcommand so sync-only flags like --updated-since parse correctly.
_PARENT_VALUE_OPTIONS = frozenset({"--base-url", "--project", "--cache-dir", "--config", "--allow-prod-writes"}) _PARENT_VALUE_OPTIONS = frozenset({"--base-url", "--project", "--cache-dir", "--config", "--api-key-file", "--allow-prod-writes"})
def inject_default_sync_command(argv: list[str]) -> list[str]: def inject_default_sync_command(argv: list[str]) -> list[str]:
@@ -3311,6 +3340,16 @@ def main(argv: list[str] | None = None) -> int:
normalized_argv = inject_default_sync_command(raw_argv) normalized_argv = inject_default_sync_command(raw_argv)
parser = build_parser() parser = build_parser()
args = parser.parse_args(normalized_argv) args = parser.parse_args(normalized_argv)
if getattr(args, "api_key_file", None):
try:
key = Path(args.api_key_file).expanduser().read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"error: Cannot read API key file {args.api_key_file}: {exc.strerror}", file=sys.stderr)
return 2
if not key:
print("error: API key file is empty; supply REDMINE_API_KEY or a nonempty --api-key-file.", file=sys.stderr)
return 2
os.environ["REDMINE_API_KEY"] = key
# Post-process common globals so subparsers (tui/browse/...) that re-declare them # Post-process common globals so subparsers (tui/browse/...) that re-declare them
# (to support "cmd --allow-prod-writes ...") don't leave them as None. # (to support "cmd --allow-prod-writes ...") don't leave them as None.
@@ -3763,11 +3802,8 @@ def main(argv: list[str] | None = None) -> int:
print(f"contact_id={cid}") print(f"contact_id={cid}")
print(f"handoff_hint=contact-show --contact-id {cid}") print(f"handoff_hint=contact-show --contact-id {cid}")
print(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer") print(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer")
print( from redmine_cli_runtime import contacts_handoff
"handoff_hint_full_crm=" print("handoff_hint_full_crm=" + contacts_handoff(args, cid))
f"redmine_contacts.py --base-url {args.base_url} --project {args.project} "
f"show --contact-id {cid}"
)
# --fetch implies show; alone it used to silently do nothing (bug). # --fetch implies show; alone it used to silently do nothing (bug).
want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False)) want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False))
want_fetch = bool(getattr(args, "fetch", False)) want_fetch = bool(getattr(args, "fetch", False))
@@ -4925,4 +4961,9 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
try:
raise SystemExit(main()) raise SystemExit(main())
except (CacheError, RedmineError) as exc:
label = "Cache error" if isinstance(exc, CacheError) else "error"
print(f"{label}: {exc}", file=sys.stderr)
raise SystemExit(75 if isinstance(exc, CacheError) else 2)
+298
View File
@@ -0,0 +1,298 @@
"""End-to-end pipeline: fetch Redmine contacts, enrich them, and upsert to CRM.
This module wires three existing building blocks together:
* redmine_crm.redmine_client for extraction
* contact_enrichment for deterministic transformation
* redmine_crm.crm_client for idempotent loading
When run as a script it provides a cron-compatible CLI. It can also be imported
and called programmatically.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from redmine_crm.crm_client import CrmClient
from redmine_crm.redmine_client import RedmineClient
from redmine_crm.redmine_client import RedmineNotFoundError
import contact_enrichment
logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://192.168.50.170"
DEFAULT_PROJECT_ID = 1 # "customer-service" numeric project id common in this Redmine instance
DEFAULT_STATE_FILE = Path(".cache/redmine_contact_sync/state.json")
DEFAULT_LOG_LEVEL = "INFO"
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class SyncMetrics:
"""Lightweight in-memory metrics collector for a single sync run."""
def __init__(self) -> None:
self.started_at = _utc_now_iso()
self.finished_at: str | None = None
self.fetched = 0
self.enriched = 0
self.skipped = 0
self.created = 0
self.updated = 0
self.unchanged = 0
self.errors = 0
def to_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at,
"finished_at": self.finished_at,
"fetched": self.fetched,
"enriched": self.enriched,
"skipped": self.skipped,
"created": self.created,
"updated": self.updated,
"unchanged": self.unchanged,
"errors": self.errors,
}
class FileSyncState:
"""Persist the last successful sync timestamp and last run metrics."""
def __init__(self, path: Path) -> None:
self.path = path
self._data: dict[str, Any] = {}
self.load()
def load(self) -> None:
if self.path.exists():
try:
self._data = json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Could not read sync state from %s: %s", self.path, exc)
self._data = {}
else:
self._data = {}
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8")
@property
def last_sync_timestamp(self) -> str | None:
return self._data.get("last_sync_timestamp")
@property
def last_metrics(self) -> dict[str, Any] | None:
return self._data.get("last_metrics")
def record_run(self, timestamp: str, metrics: SyncMetrics) -> None:
self._data["last_sync_timestamp"] = timestamp
self._data["last_metrics"] = metrics.to_dict()
self.save()
def _configure_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
def _redmine_client_from_args(args: argparse.Namespace) -> RedmineClient:
api_key = os.getenv("REDMINE_API_KEY")
if not api_key:
raise RuntimeError("Set REDMINE_API_KEY in the environment.")
return RedmineClient(
base_url=args.base_url,
api_key=api_key,
project_id=args.project_id,
)
def _enrichment_config_for_args(args: argparse.Namespace) -> contact_enrichment.EnrichmentConfig:
domain_map: dict[str, str] = {}
if args.domain_to_company:
for item in args.domain_to_company:
if "=" not in item:
raise ValueError(f"Invalid --domain-to-company value: {item!r} (expected domain=company)")
domain, company = item.split("=", 1)
domain_map[domain.strip()] = company.strip()
config = contact_enrichment.EnrichmentConfig(
source=args.source,
confidence_threshold=args.confidence_threshold,
default_country_code=args.default_country_code,
domain_to_company=domain_map,
)
if args.external_lookup:
# Expect a module.path:callable string, e.g. mymodule.plugins:lookup_company
mod_name, _, func_name = args.external_lookup.partition(":")
if not mod_name or not func_name:
raise ValueError("--external-lookup must be module.path:callable")
from importlib import import_module
module = import_module(mod_name)
config.external_lookup = getattr(module, func_name)
return config
def run_pipeline(
*,
redmine: RedmineClient,
crm: CrmClient,
enrichment_config: contact_enrichment.EnrichmentConfig | None = None,
state: FileSyncState | None = None,
batch_size: int = 100,
dry_run: bool = False,
) -> dict[str, Any]:
"""Run the full fetch -> enrich -> upsert pipeline.
Returns a dict containing final metrics and the timestamp of this run.
"""
metrics = SyncMetrics()
enrichment_config = enrichment_config or contact_enrichment.EnrichmentConfig()
state = state or FileSyncState(DEFAULT_STATE_FILE)
logger.info("Starting Redmine -> CRM sync; dry_run=%s last_sync=%s", dry_run, state.last_sync_timestamp)
# Extract
raw_contacts = redmine.iter_all_contacts(
updated_since=state.last_sync_timestamp,
batch_size=batch_size,
)
metrics.fetched = len(raw_contacts)
logger.info("Fetched %d Redmine contacts", metrics.fetched)
# Transform
enrichment_results = contact_enrichment.build_sync_payload(raw_contacts, enrichment_config)
records = enrichment_results["records"]
skipped = enrichment_results["skipped"]
metrics.enriched = len(records)
metrics.skipped = len(skipped)
logger.info("Enriched %d contacts, skipped %d", metrics.enriched, metrics.skipped)
for item in skipped:
logger.info("Skipped Redmine contact %s: %s", item.get("redmine_contact_id"), item.get("reason"))
# Load (skipped during dry runs so real CRM adapters can also be avoided)
if dry_run:
logger.info("Dry run: skipping CRM upsert")
else:
stats = crm.sync_batch(records)
metrics.created = stats.get("created", 0)
metrics.updated = stats.get("updated", 0)
metrics.unchanged = stats.get("skipped", 0)
metrics.errors = stats.get("errors", 0)
if stats.get("error_records"):
for err in stats["error_records"]:
logger.warning("CRM upsert error for Redmine contact %s: %s", err.get("redmine_contact_id"), err.get("reason"))
metrics.finished_at = _utc_now_iso()
result = {
"timestamp": metrics.finished_at,
"dry_run": dry_run,
"metrics": metrics.to_dict(),
"skipped_records": skipped,
}
if not dry_run:
state.record_run(result["timestamp"], metrics)
return result
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Sync Redmine CRM contacts into a target CRM.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--base-url", default=os.getenv("REDMINE_BASE_URL", DEFAULT_BASE_URL), help="Redmine base URL.")
parser.add_argument(
"--project-id",
type=int,
default=int(os.getenv("REDMINE_PROJECT_ID", str(DEFAULT_PROJECT_ID))),
help="Redmine numeric project id for contact filtering.",
)
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_FILE, help="Path to the persistent sync state file.")
parser.add_argument("--batch-size", type=int, default=100, help="Redmine API page size.")
parser.add_argument("--dry-run", action="store_true", help="Fetch and enrich but do not write to CRM.")
parser.add_argument("--log-level", default=DEFAULT_LOG_LEVEL, choices=("DEBUG", "INFO", "WARNING", "ERROR"))
parser.add_argument("--source", default="redmine", help="Value to set in the source field.")
parser.add_argument("--confidence-threshold", type=float, default=0.7, help="Minimum enrichment confidence.")
parser.add_argument("--default-country-code", default="US", help="Default country code for phone normalization.")
parser.add_argument(
"--domain-to-company",
action="append",
metavar="DOMAIN=COMPANY",
help="Override company mapping for a specific email domain. Repeatable.",
)
parser.add_argument(
"--external-lookup",
help="Optional module.path:callable providing external company lookup.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_argument_parser()
args = parser.parse_args(argv)
_configure_logging(args.log_level)
redmine = _redmine_client_from_args(args)
enrichment_config = _enrichment_config_for_args(args)
# The default in-memory CRM client is useful for local testing and dry runs.
crm = CrmClient()
state = FileSyncState(args.state_file)
try:
result = run_pipeline(
redmine=redmine,
crm=crm,
enrichment_config=enrichment_config,
state=state,
batch_size=args.batch_size,
dry_run=args.dry_run,
)
except Exception as exc:
metrics = SyncMetrics()
try:
result = _handle_not_found_as_empty(exc, metrics)
if not args.dry_run:
state.record_run(result["timestamp"], metrics)
except Exception:
logger.exception("Pipeline failed: %s", exc)
return 1
print(json.dumps(result, indent=2, sort_keys=True))
return 0
def _handle_not_found_as_empty(exc: Exception, metrics: SyncMetrics) -> dict[str, Any]:
"""Treat a 404 from Redmine contacts as an empty sync (plugin may be absent)."""
if isinstance(exc, RedmineNotFoundError):
logger.warning("Redmine contacts endpoint returned 404; treating as empty sync.")
metrics.finished_at = _utc_now_iso()
return {
"timestamp": metrics.finished_at,
"dry_run": False,
"metrics": metrics.to_dict(),
"skipped_records": [],
}
raise
if __name__ == "__main__":
raise SystemExit(main())
+165
View File
@@ -0,0 +1,165 @@
"""Real process tests: no placeholder success assertions or live API access."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import pytest
from redmine_cache_io import CacheError, atomic_write_json, cache_lock
ROOT=Path(__file__).resolve().parents[1]
def spawn(code,*args,extra=None):
return subprocess.Popen([sys.executable,'-c',code,*map(str,args)],cwd=ROOT,
env={**os.environ,**(extra or {})},stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
def finish(proc):
out,err=proc.communicate(timeout=15)
assert proc.returncode==0,err
return out,err
def test_distinct_ticket_updates_from_stale_instances_survive(tmp_path):
code='''from redmine_tickets import TicketCache
from pathlib import Path
import sys,time
c=TicketCache(Path(sys.argv[1]),'customer-service')
print('ready',flush=True)
time.sleep(.2)
for n in range(8): c.write_issue({'id':int(sys.argv[2])*100+n,'subject':'fixture','updated_on':str(n)})
'''
children=[spawn(code,tmp_path,n) for n in range(3)]
for child in children:
assert child.stdout.readline().strip()=='ready'
for child in children: finish(child)
data=json.loads((tmp_path/'manifest.json').read_text())
assert len(data['tickets'])==24
assert len(list((tmp_path/'customer-service').glob('*.json')))==24
def test_distinct_contact_updates_survive(tmp_path):
code='''from redmine_ticket_contacts import ContactCache
from pathlib import Path
import sys,time
c=ContactCache(Path(sys.argv[1]))
for n in range(10): c.write_contact({'id':int(sys.argv[2])*100+n,'first_name':'fixture'})
'''
children=[spawn(code,tmp_path,n) for n in range(3)]
for child in children: finish(child)
data=json.loads((tmp_path/'contacts/manifest.json').read_text())
assert len(data['contacts'])==30
def test_readers_see_complete_json_during_writes(tmp_path):
path=tmp_path/'data.json'
atomic_write_json(path,{'n':0,'blob':'x'*10000})
child=spawn('''from redmine_cache_io import atomic_write_json
from pathlib import Path
import sys
for n in range(60): atomic_write_json(Path(sys.argv[1]),{'n':n,'blob':'x'*10000})
''',path)
reads=0
while child.poll() is None:
data=json.loads(path.read_text())
assert len(data['blob'])==10000
reads+=1
finish(child)
assert reads>0
def holder(path):
child=spawn('''from redmine_cache_io import cache_lock
from pathlib import Path
import sys,time
with cache_lock(Path(sys.argv[1])):
print('locked',flush=True)
time.sleep(20)
''',path)
assert child.stdout.readline().strip()=='locked'
return child
def test_contention_timeout_is_visible_and_nonzero(tmp_path):
path=tmp_path/'manifest.json'; child=holder(path)
try:
waiter=spawn('''from redmine_cache_io import atomic_write_json
from pathlib import Path
import sys
atomic_write_json(Path(sys.argv[1]),{'x':1})
''',path,extra={'REDMINE_LOCK_TIMEOUT':'.12'})
out,err=waiter.communicate(timeout=5)
assert waiter.returncode!=0
assert 'Waiting for cache lock' in err
assert 'timeout after 0.12s' in err
finally:
child.kill();child.communicate(timeout=5)
def test_sigkill_releases_existing_lock_file(tmp_path):
path=tmp_path/'manifest.json'; child=holder(path)
child.kill(); child.communicate(timeout=5)
assert path.with_name(path.name+'.lock').exists()
waiter=spawn('''from redmine_cache_io import atomic_write_json
from pathlib import Path
import sys
atomic_write_json(Path(sys.argv[1]),{'recovered':True})
''',path,extra={'REDMINE_LOCK_TIMEOUT':'.2'})
finish(waiter)
assert json.loads(path.read_text())=={'recovered':True}
@pytest.mark.parametrize('value',['bogus','-1','nan','inf'])
def test_invalid_timeout_is_actionable(tmp_path,monkeypatch,value):
monkeypatch.setenv('REDMINE_LOCK_TIMEOUT',value)
with pytest.raises(CacheError,match='REDMINE_LOCK_TIMEOUT'):
atomic_write_json(tmp_path/'x.json',{})
def test_stale_temporary_file_is_removed_on_next_lock(tmp_path):
path=tmp_path/'manifest.json'
stale=tmp_path/'.manifest.json.killed.tmp'
stale.write_text('partial')
atomic_write_json(path,{'recovered':True})
assert not stale.exists()
assert json.loads(path.read_text())=={'recovered':True}
def test_nested_lock_and_atomic_failure_preserves_original(tmp_path):
path=tmp_path/'data.json'
atomic_write_json(path,{'old':True})
with cache_lock(path,timeout=.1):
with pytest.raises(TypeError): atomic_write_json(path,{'bad':object()})
assert json.loads(path.read_text())=={'old':True}
assert not list(tmp_path.glob('*.tmp'))
def test_corrupt_contact_manifest_not_reset(tmp_path):
from redmine_ticket_contacts import ContactCache
c=ContactCache(tmp_path)
c.manifest_path.write_text('{broken')
with pytest.raises(CacheError): c.write_contact({'id':1})
assert c.manifest_path.read_text()=='{broken'
def test_snapshot_fetches_are_serialized_before_replacement(tmp_path):
code='''from redmine_contacts import snapshot_transaction,write_cache
from argparse import Namespace
from pathlib import Path
import sys,time
args=Namespace(cache=Path(sys.argv[1]),base_url='https://test.example',project='p',refresh=True)
@snapshot_transaction
def fetch(args):
print('fetch',flush=True)
time.sleep(float(sys.argv[2]))
write_cache(args.cache,args,[{'id':int(sys.argv[3])}])
fetch(args)
'''
path=tmp_path/'contacts.json'
first=spawn(code,path,.3,1)
assert first.stdout.readline().strip()=='fetch'
second=spawn(code,path,0,2)
finish(first); _,err=finish(second)
assert 'Waiting for cache lock' in err
assert json.loads(path.read_text())['contacts']==[{'id':2}]
+187
View File
@@ -0,0 +1,187 @@
"""Run real wrapper entry points in a relocated checkout with an isolated HOME."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT=Path(__file__).resolve().parents[1]
@pytest.fixture
def runtime(tmp_path):
root=tmp_path/'relocated checkout'
(root/'bin').mkdir(parents=True)
shutil.copy(ROOT/'redmine_cli_runtime.py',root)
for name in ('rcontacts-live','rtickets-live','rtl','rtd'):
shutil.copy(ROOT/'bin'/name,root/'bin'/name)
fake='import json,os,sys; print(json.dumps({"argv":sys.argv[1:],"env":{k:v for k,v in os.environ.items() if k.startswith("REDMINE_") or k.startswith("NMI_")}}))\n'
for name in ('redmine_tickets.py','redmine_contacts.py'):
(root/name).write_text(fake)
home=tmp_path/'home'; home.mkdir()
env={'HOME':str(home),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR']}
def run(*args,command='rtl',extra=None):
return subprocess.run([str(root/'bin'/command),*args],cwd=tmp_path,env={**env,**(extra or {})},text=True,capture_output=True,timeout=10)
return root,home,run
def test_config_file_and_export_precedence(runtime):
root,home,run=runtime
(home/'.redmine.live').write_text('export REDMINE_API_KEY="fixture-secret"\nREDMINE_BASE_URL=https://file.example.test\nREDMINE_PROJECT=abc\nREDMINE_BASE_CACHE="data with spaces"\n')
p=run('--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'})
assert p.returncode==0,p.stderr
d=json.loads(p.stdout)
assert d['base_url']=='https://env.example.test'
assert d['project']=='abc'
assert d['base_cache']==str(home/'data with spaces')
assert d['api_key_present']
assert 'fixture-secret' not in p.stdout+p.stderr
assert 'environment' in d['sources']['base_url']
p=run('--base-url','https://cli.example.test','--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'})
assert json.loads(p.stdout)['base_url']=='https://cli.example.test'
def test_real_dispatch_preserves_stdin_arguments_and_key(runtime):
root,home,run=runtime
(home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-secret\n')
p=run('--base-cache','my cache','search','--query','two words',command='rcontacts-live')
assert p.returncode==0,p.stderr
d=json.loads(p.stdout)
assert d['env']['REDMINE_API_KEY']=='fixture-secret'
assert d['argv'][-3:]==['search','--query','two words']
cache=d['argv'][d['argv'].index('--cache')+1]
assert cache.endswith('my cache/redmine_contacts/rm.ldrprep.com_customer-service.json')
assert not Path(cache).is_dir()
def test_doctor_missing_key_nonzero_with_search_paths(runtime):
root,home,run=runtime
p=run('--doctor')
assert p.returncode==2
d=json.loads(p.stdout)
assert not d['ready']
assert 'REDMINE_API_KEY' in d['problems'][0]
assert str(home/'.redmine.live') in d['searched_env_paths']
assert not (root/'cache').exists()
p=run('sync')
assert p.returncode==2 and 'REDMINE_API_KEY' in p.stderr
def test_explicit_missing_or_malformed_file_blocks(runtime):
root,home,run=runtime
assert run('--env-file','absent','--doctor').returncode==2
file=home/'bad.env'; file.write_text('not an assignment\n')
p=run('--env-file',str(file),'--show-config')
assert p.returncode==2 and 'Malformed' in p.stderr
def test_help_works_without_config(runtime):
_,_,run=runtime
p=run('--env-file','missing','--help')
assert p.returncode==0
assert 'REDMINE_API_KEY' in p.stdout
def test_aliases_match_and_dev_never_loads_live(runtime):
root,home,run=runtime
(home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-live\n')
a=json.loads(run('--show-config',command='rtl').stdout)
b=json.loads(run('--show-config',command='rtickets-live').stdout)
assert a==b
d=run('--show-config',command='rtd')
data=json.loads(d.stdout)
assert not data['api_key_present']
assert data['nmi_mode']=='dev'
assert 'DEVELOPMENT' in d.stderr
assert data['ticket_cache']==str(root/'cache-dev/redmine_tickets')
def test_cache_overrides_and_scope_isolation(runtime):
root,_,run=runtime
default=json.loads(run('--show-config').stdout)
assert default['ticket_cache']==str(root/'cache/redmine_tickets')
assert default['contacts_cache'].endswith('/cache/redmine_contacts/rm.ldrprep.com_customer-service.json')
assert default['contacts_cache_layout']=='namespaced-file'
other=json.loads(run('--project','meetings','--show-config').stdout)
assert other['ticket_cache']!=default['ticket_cache']
host=json.loads(run('--base-url','https://other.example.test','--show-config').stdout)
assert host['ticket_cache']!=default['ticket_cache']
exact=json.loads(run('--cache-dir','specific path','--show-config').stdout)
assert exact['ticket_cache'].endswith('/specific path')
def test_json_config_and_key_file_propagation(runtime):
root,home,run=runtime
config=home/'live.json'; key=home/'key'; key.write_text('fixture-key-file\n')
config.write_text(json.dumps({'project':'meetings','cache-dir':'mirror','api-key-file':'key'}))
p=run('--config',str(config),'search',extra={'REDMINE_PROJECT':'overridden'})
assert p.returncode==0,p.stderr
d=json.loads(p.stdout)
assert d['env']['REDMINE_API_KEY']=='fixture-key-file'
assert d['env']['REDMINE_PROJECT']=='overridden'
assert '--config' not in d['argv']
assert str(home/'mirror') in d['argv']
def test_subcommand_config_is_not_global(runtime):
_,_,run=runtime
p=run('materialize-queues','--config','queues.json')
assert p.returncode==0,p.stderr
assert json.loads(p.stdout)['argv'][-3:]==['materialize-queues','--config','queues.json']
def test_directory_contacts_cache_rejected(runtime):
root,_,run=runtime
p=run('--cache',str(root),'--show-config',command='rcontacts-live')
assert p.returncode==2 and 'JSON file' in p.stderr
def test_legacy_contacts_snapshot_is_preserved(runtime):
root,_,run=runtime
legacy=root/'cache/redmine_contacts'
legacy.parent.mkdir(parents=True)
legacy.write_text(json.dumps({'base_url':'https://rm.ldrprep.com','project':'customer-service','contacts':[]}))
d=json.loads(run('--show-config').stdout)
assert d['contacts_cache']==str(legacy)
assert d['contacts_cache_layout']=='legacy-single-file'
def test_contacts_handoff_preserves_resolved_context(tmp_path,monkeypatch):
import argparse
import shlex
from redmine_cli_runtime import contacts_handoff
monkeypatch.setenv('REDMINE_ENV_FILE',str(tmp_path/'profile with spaces.env'))
monkeypatch.setenv('REDMINE_CONTACTS_CACHE',str(tmp_path/'cache with spaces/contacts.json'))
args=argparse.Namespace(base_url='https://rm.example.test',project='meetings',cache_dir=tmp_path/'tickets',api_key_file=tmp_path/'key file')
cmd=shlex.split(contacts_handoff(args,42))
assert cmd[cmd.index('--cache')+1]==str(tmp_path/'cache with spaces/contacts.json')
assert cmd[cmd.index('--env-file')+1]==str(tmp_path/'profile with spaces.env')
assert cmd[cmd.index('--api-key-file')+1]==str(tmp_path/'key file')
assert cmd[-3:]==['show','--contact-id','42']
def test_actual_wrapper_invalid_ticket_query_is_clean_error(tmp_path):
env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR'],
'REDMINE_API_KEY':'fixture-only','REDMINE_BASE_CACHE':str(tmp_path/'cache')}
p=subprocess.run([str(ROOT/'bin/rtl'),'search','--where','id == -1'],env=env,cwd=tmp_path,
text=True,capture_output=True,timeout=10)
assert p.returncode==2
assert 'Invalid --where expression' in p.stderr
assert 'Traceback' not in p.stderr
def test_actual_wrapper_to_actual_nmi_doctor(tmp_path):
cli=ROOT.parent/'mrscClient-php/bin/nmi'
if not cli.exists(): pytest.skip('Sibling NMI checkout unavailable')
profile=tmp_path/'nmi.env'
profile.write_text('NMI_ENDPOINT=https://fixture.example.test\nNMI_MRSC_ACCESS_CODE=fixture-access\nNMI_SECRETKEY=fixture-secret\n')
redmine=tmp_path/'redmine.env'
redmine.write_text(f'NMI_CLI={cli}\nNMI_ENV_FILE={profile}\nREDMINE_BASE_CACHE={tmp_path}/cache\n')
p=subprocess.run([str(ROOT/'bin/rtickets-live'),'--env-file',str(redmine),'--doctor-nmi'],
env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin'},cwd=tmp_path,text=True,capture_output=True,timeout=10)
assert p.returncode==0,p.stdout+p.stderr
d=json.loads(p.stdout)
assert d['ready'] and d['mode']=='production'
assert d['env_file']==str(profile)
assert not d['network_checked']
assert 'fixture-secret' not in p.stdout+p.stderr
+160
View File
@@ -0,0 +1,160 @@
"""Portable runtime contract tests; fake child responses never contact NMI."""
import json
import subprocess
from pathlib import Path
import pytest
import redmine_ticket_nmi as bridge
def fake_cli(tmp_path):
p = tmp_path / 'space dir' / 'nmi'
p.parent.mkdir()
p.write_text('<?php // test fixture\n')
return p
def doctor_result(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, json.dumps({
'ready': True, 'mode': 'production', 'env_file': None,
'endpoint': 'https://nmi.example.test/api', 'problem': None,
}), '')
def test_environment_only_runtime_uses_actual_doctor(monkeypatch, tmp_path):
cli = fake_cli(tmp_path)
calls = []
def invoke(cmd, **kwargs):
calls.append((cmd, kwargs))
return doctor_result(cmd, **kwargs)
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
profile = bridge.resolve_runtime_profile(environ={
'NMI_CLI': str(cli), 'NMI_SECRETKEY': 'fixture-secret',
'NMI_MRSC_ACCESS_CODE': 'fixture-access',
'NMI_ENDPOINT': 'https://nmi.example.test/api',
})
assert profile.ready
assert profile.mode == 'production'
assert profile.env_file is None
assert len(calls) == 1
cmd, kwargs = calls[0]
assert '--doctor' in cmd and '--json' in cmd
assert not any(arg.startswith('--env-file') for arg in cmd)
assert 'fixture-secret' not in ' '.join(cmd)
assert kwargs['env']['NMI_SECRETKEY'] == 'fixture-secret'
def test_mode_and_binary_are_resolved_at_call_time(monkeypatch, tmp_path):
cli = fake_cli(tmp_path)
monkeypatch.setenv('NMI_CLI', str(cli))
monkeypatch.setenv('NMI_MODE', 'dev')
monkeypatch.delenv('NMI_ENV_FILE', raising=False)
calls = []
def invoke(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, '{}', '')
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
bridge.run_nmi(['resolve', '--item-no=200000001'])
assert str(cli) in calls[0]
assert '--mode=dev' in calls[0]
assert not any('None' in value for value in calls[0])
def test_explicit_missing_env_blocks_child(monkeypatch, tmp_path):
monkeypatch.setattr(bridge.subprocess, 'run', lambda *a, **k: pytest.fail('must not spawn'))
p = bridge.resolve_runtime_profile(env_file=str(tmp_path / 'absent'), nmi_bin=str(fake_cli(tmp_path)), environ={})
assert not p.ready
assert 'does not exist' in p.problem
def test_doctor_nonzero_is_not_ready(monkeypatch, tmp_path):
def invoke(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 2, json.dumps({'ready': False, 'problem': 'NMI_SECRETKEY is missing'}), '')
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
p = bridge.resolve_runtime_profile(nmi_bin=str(fake_cli(tmp_path)), environ={})
assert not p.ready
assert 'NMI_SECRETKEY' in p.problem
def test_doctor_timeout_actionable(monkeypatch, tmp_path):
def invoke(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, 15)
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
p = bridge.resolve_runtime_profile(nmi_bin=str(fake_cli(tmp_path)), environ={})
assert not p.ready
assert 'timed out' in p.problem.lower()
def test_brief_executes_resolved_profile_not_import_defaults(monkeypatch, tmp_path):
cli = fake_cli(tmp_path)
env_file = tmp_path / 'selected.env'
env_file.write_text('NMI_MODE=production\n')
profile = bridge.NmiRuntimeProfile('production', env_file, cli, 'https://nmi.example.test/api', True)
monkeypatch.setattr(bridge, 'resolve_runtime_profile', lambda **kwargs: profile)
calls = []
def brief(plan, **kwargs):
calls.append(kwargs)
return {'ok': True, 'text': '=== NMI CS BRIEF ===\nfixture'}
monkeypatch.setattr(bridge, 'try_cs_brief', brief)
result = bridge.brief_for_issue({'id': 1}, [{'type': 'item_no', 'value': '200000001'}], with_history=False)
assert result['ok']
assert calls[0]['nmi_bin'] == str(cli)
assert calls[0]['env_file'] == str(env_file)
assert calls[0]['endpoint'] == profile.endpoint
def test_history_uses_one_item_identifier(monkeypatch):
seen = []
monkeypatch.setattr(bridge, 'try_item_history', lambda item, **kwargs: seen.append(item) or {'ok': True})
bridge.enrich_with_history({'plan': {'item_no': ['200000001', '200000002']}})
assert seen == ['200000001']
def test_real_php_doctor_integration(tmp_path):
"""Executes sibling NMI's real config resolver, not its network API."""
import os
import shutil
cli = Path(bridge.DEFAULT_NMI_BIN)
if not cli.is_file() or not shutil.which('php'):
pytest.skip('Sibling NMI/PHP not available')
env_file = tmp_path / 'test profile.env'
env_file.write_text('NMI_MRSC_ACCESS_CODE=fixture-access\nNMI_SECRETKEY=fixture-secret\nNMI_ENDPOINT=https://fixture.example.test\n')
profile = bridge.resolve_runtime_profile(environ={
'HOME': str(tmp_path), 'PATH': '/usr/bin:/bin', 'NMI_CLI': str(cli),
'NMI_ENV_FILE': str(env_file),
})
assert profile.ready, profile.problem
assert profile.mode == 'production'
assert profile.env_file == env_file
assert profile.endpoint == 'https://fixture.example.test'
def test_real_php_child_receives_same_runtime_for_brief_and_history(tmp_path, monkeypatch):
"""Fake PHP service-facing CLI; exercises real subprocess argv forwarding."""
import shutil
if not shutil.which('php'):
pytest.skip('PHP unavailable')
cli = tmp_path / 'fake nmi.php'
log = tmp_path / 'calls.jsonl'
env_file = tmp_path / 'profile.env'
env_file.write_text('NMI_MODE=dev\n')
cli.write_text('''<?php
file_put_contents(getenv('BRIDGE_TEST_LOG'), json_encode($argv)."\\n", FILE_APPEND);
if (in_array('--doctor', $argv, true)) {
echo json_encode(['ready'=>true,'mode'=>'dev','env_file'=>getenv('NMI_ENV_FILE'),'endpoint'=>'https://fixture.example.test','problem'=>null]);
} elseif (in_array('cs-brief',$argv,true)) {
echo "=== NMI CS BRIEF === fixture only\\n";
} else { echo json_encode(['fixture'=>true]); }
''')
monkeypatch.setenv('NMI_CLI', str(cli))
monkeypatch.setenv('NMI_ENV_FILE', str(env_file))
monkeypatch.setenv('NMI_MODE', 'dev')
monkeypatch.setenv('BRIDGE_TEST_LOG', str(log))
result = bridge.brief_for_issue({'id':1}, [{'type':'item_no','value':'200000001'}])
assert result['ok'] and result['item_history']['ok']
calls = [json.loads(line) for line in log.read_text().splitlines()]
assert len(calls) == 3
for call in calls:
assert '--mode=dev' in call
assert '--env-file='+str(env_file) in call
assert '--item-no=200000001' in calls[-1]
+10 -1
View File
@@ -9,9 +9,18 @@ import pytest
import redmine_ticket_nmi import redmine_ticket_nmi
def test_runtime_profile_honors_explicit_production_mode_and_env_file(tmp_path): def test_runtime_profile_honors_explicit_production_mode_and_env_file(tmp_path, monkeypatch):
production_env = tmp_path / "production.env" production_env = tmp_path / "production.env"
production_env.write_text("NMI_ENDPOINT_PRODUCTION=https://app.ldrprep.com/api.php\n") production_env.write_text("NMI_ENDPOINT_PRODUCTION=https://app.ldrprep.com/api.php\n")
# File existence alone is no longer readiness. Simulate the offline PHP
# diagnostic here; real CLI resolution is exercised by the integration tests.
import json
import subprocess
monkeypatch.setattr(redmine_ticket_nmi.subprocess, "run", lambda cmd, **kwargs:
subprocess.CompletedProcess(cmd, 0, json.dumps({
"ready": True, "mode": "production", "env_file": str(production_env),
"endpoint": "https://app.ldrprep.com/api.php", "problem": None,
}), ""))
profile = redmine_ticket_nmi.resolve_runtime_profile( profile = redmine_ticket_nmi.resolve_runtime_profile(
environ={ environ={
+1 -1
View File
@@ -224,7 +224,7 @@ class RedmineTicketsSyncTest(unittest.TestCase):
self.assertEqual(2, result.seen) self.assertEqual(2, result.seen)
self.assertEqual(1, result.skipped_cached) self.assertEqual(1, result.skipped_cached)
self.assertEqual(1, result.fetched) self.assertEqual(1, result.fetched)
self.assertEqual([(101, ("journals", "helpdesk"))], client.full_fetches) self.assertEqual([(101, ("journals", "helpdesk", "attachments"))], client.full_fetches)
self.assertTrue(cache.issue_path(101).is_file()) self.assertTrue(cache.issue_path(101).is_file())