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-dev/*
./run-stats.json
manifest.json
/.venv/
/__pycache__/
/redmine-copy/
@@ -10,3 +15,12 @@ redMCP/composer.phar
.env
semantic_index/.env
*.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
# Muscle-memory wrapper: LAN/dev customer-service cache + base URL.
# Usage: rtd note --issue-id 39305 --body 'test' --apply
# Portable tickets dev entry point. No shell evaluation of credentials.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/dev.json" "$@"
ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
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
# Muscle-memory wrapper: prod customer-service cache + base URL.
# Usage: rtl search --where 'assigned_to IS NULL' --max-tickets 10
# Portable tickets live entry point. No shell evaluation of credentials.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# rtl is the production Redmine wrapper. Keep an explicitly supplied mode, but
# 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" "$@"
ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@"
+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 json
from functools import wraps
from redmine_cache_io import CacheError, cache_lock, atomic_write_json
import os
import re
import subprocess
@@ -497,6 +499,7 @@ def main() -> int:
try:
if args.command == "fetch":
with cache_lock(resolve_cache_path(args)):
client = client_from_args(args)
contacts = client.fetch_contacts(limit=args.limit)
filtered_contacts = filter_contacts_from_args(contacts, args)
@@ -627,7 +630,7 @@ def main() -> int:
client = client_from_args(args)
print_json(client.helpdesk_timeline(args.contact_id, args.limit))
return 0
except RedmineError as exc:
except (RedmineError, CacheError) as exc:
print(f"error: {exc}", file=sys.stderr)
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]]:
cache_path = resolve_cache_path(args)
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
@snapshot_transaction
def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]:
source = args.source
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]]:
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()),
"contacts": contacts,
}
with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
atomic_write_json(cache_path, payload)
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
import json
from redmine_cache_io import cache_lock, read_json, atomic_write_json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
@@ -34,12 +35,7 @@ class ContactCache:
def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file():
return {"version": 1, "contacts": {}, "updated_at": None}
try:
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 = read_json(self.manifest_path)
data.setdefault("version", 1)
data.setdefault("contacts", {})
return data
@@ -48,9 +44,7 @@ class ContactCache:
data = dict(data)
data["version"] = 1
data["updated_at"] = now_iso()
tmp = self.manifest_path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(self.manifest_path)
atomic_write_json(self.manifest_path, data)
def should_fetch(self, stub: dict[str, Any]) -> bool:
cid = stub.get("id")
@@ -64,11 +58,12 @@ class ContactCache:
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
def write_contact(self, contact: dict[str, Any]) -> Path:
with cache_lock(self.manifest_path):
cid = contact.get("id")
if cid is None:
raise ValueError("contact missing id")
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()
contacts = manifest.setdefault("contacts", {})
contacts[str(int(cid))] = {
+168 -156
View File
@@ -16,16 +16,21 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
# Prefer env overrides for agents/profiles
DEFAULT_NMI_BIN = os.environ.get(
"NMI_CLI",
"/home/jason/projects/mrscClient-php/bin/nmi",
)
DEFAULT_ENV_FILE = os.environ.get(
"NMI_ENV_FILE",
"/home/jason/projects/mrscClient-php/examples/modern/.env",
)
DEFAULT_MODE = os.environ.get("NMI_MODE", "dev")
# Compatibility constants are environment-independent. Resolve overrides at call
# time so a TUI/worker cannot retain a profile captured during module import.
DEFAULT_NMI_BIN = str(Path(__file__).resolve().parent.parent / "mrscClient-php" / "bin" / "nmi")
DEFAULT_ENV_FILE = None # PHP owns default env-file discovery.
DEFAULT_MODE = "production"
def discover_nmi_bin(value: Optional[str] = None, *, environ=None) -> Path:
env = os.environ if environ is None else environ
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
_TRACKING_TYPES = {
@@ -64,7 +69,7 @@ class NmiRuntimeProfile:
"""Resolved NMI execution settings plus a preflight result."""
mode: str
env_file: Path
env_file: Optional[Path]
nmi_bin: Path
endpoint: Optional[str]
ready: bool
@@ -79,61 +84,65 @@ def resolve_runtime_profile(
endpoint: Optional[str] = None,
environ: Optional[dict[str, str]] = None,
) -> 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
the checked-in example `.env` to otherwise select a surprising dev/default
path while making the TUI look successful.
With no explicit env file, let NMI perform its own documented discovery.
Environment-only credentials are valid. No service requests are performed.
"""
env = os.environ if environ is None else environ
env = dict(os.environ if environ is None else environ)
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
selected_env_file = Path(env_file or env.get("NMI_ENV_FILE") or DEFAULT_ENV_FILE).expanduser()
selected_nmi_bin = Path(nmi_bin or env.get("NMI_CLI") or DEFAULT_NMI_BIN).expanduser()
normalized_mode = "production" if requested_mode == "prod" else requested_mode
requested_file = env_file or env.get("NMI_ENV_FILE")
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()
selected_endpoint = (
endpoint
or env.get(f"NMI_ENDPOINT_{mode_upper}")
or env.get("NMI_ENDPOINT")
or None
)
def failed(problem):
return NmiRuntimeProfile(normalized_mode, selected_env_file, selected_nmi_bin, endpoint, False, problem)
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():
return NmiRuntimeProfile(
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}",
)
return failed(f"NMI env file does not exist: {selected_env_file}")
if selected_env_file.stat().st_size == 0:
return NmiRuntimeProfile(
mode=normalized_mode,
env_file=selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=False,
problem=f"NMI env file is empty: {selected_env_file}",
)
return failed(f"NMI env file is empty: {selected_env_file}")
except OSError as exc:
return failed(f"Cannot inspect NMI env file: {exc}")
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(
mode=normalized_mode,
env_file=selected_env_file,
mode=data.get("mode") or normalized_mode,
env_file=Path(resolved_file) if resolved_file else selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=False,
problem=f"NMI CLI does not exist: {selected_nmi_bin}",
)
return NmiRuntimeProfile(
mode=normalized_mode,
env_file=selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint,
ready=True,
endpoint=data.get("endpoint") or endpoint,
ready=ready,
problem=None if ready else (data.get("problem") or f"NMI doctor failed (exit {proc.returncode})."),
)
@@ -223,30 +232,35 @@ def _php_bin() -> str:
def run_nmi(
args: list[str],
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
timeout: int = 60,
) -> 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:
cmd.append(f"--endpoint={endpoint}")
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 124, "", f"NMI command timed out after {timeout} seconds.")
except OSError as exc:
return subprocess.CompletedProcess(cmd, 127, "", f"Could not start NMI CLI: {exc}")
def try_resolve_ids(
plan: NmiLookupPlan,
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
include_pictures: bool = False,
max_attempts: int = 6,
@@ -294,9 +308,9 @@ def try_resolve_ids(
def try_cs_brief(
plan: NmiLookupPlan,
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
max_attempts: int = 6,
) -> dict[str, Any]:
@@ -336,9 +350,9 @@ def brief_for_issue(
issue: dict[str, Any],
entities: list[dict[str, str]],
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
with_history: bool = True,
) -> dict[str, Any]:
@@ -350,7 +364,7 @@ def brief_for_issue(
)
runtime = {
"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),
"endpoint": profile.endpoint,
"ready": profile.ready,
@@ -400,6 +414,11 @@ def brief_for_issue(
out["error"] = "no_nmi_candidates"
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)
out["brief"] = brief
out["ok"] = bool(brief.get("ok"))
@@ -416,69 +435,78 @@ def brief_for_issue(
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:
- Deep NMI links (Item Manager, Box History, Request pages etc.) per the WIP note
- Item history from the new getItemHistory endpoint (billings, who moved, condition changes)
Focuses on the fields operators actually need:
- Clear identifiers (order_id / request_id)
- 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 = [
"=== Auto NMI pre-fetch (agent) ===",
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 []
links = generate_nmi_links(ents)
if links:
lines.append("")
lines.append("=== NMI Deep Links (Item Manager / Box History / Request pages / userInfo) ===")
lines.extend(links[:12])
lines.append("=== NMI Deep Links ===")
lines.extend(links[:6])
brief = payload.get("brief") or {}
if brief.get("ok") and brief.get("text"):
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')}")
brief_text = brief.get("text", "") if brief.get("ok") else ""
# New history
hist = payload.get("item_history") or {}
if hist.get("ok"):
lines.append("")
lines.append("=== Item History (getItemHistory) ===")
h = hist.get("history") or {}
if isinstance(h, dict) and h:
lines.append(json.dumps(h, indent=2, default=str)[:1600])
elif hist.get("text"):
lines.append(hist["text"][:1400])
else:
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")))
# Extract key fields from cs-brief if present (lightweight parsing)
customer_notes = ""
items_section = []
if brief_text:
# Very lightweight extraction — look for common patterns in cs-brief output
for line in brief_text.splitlines():
l = line.strip()
if l.lower().startswith("customer") and "note" in l.lower():
customer_notes = l
if l.lower().startswith(("merchant_sku", "product_name", "item")):
items_section.append(l)
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("_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"
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(
item_no: str,
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
limit: int = 50,
snapshot: bool = True,
) -> dict[str, Any]:
"""Call nmi item history for a single item_no. Returns structured result."""
cmd = [
"php",
nmi_bin,
"item",
"history",
f"--item-no={item_no}",
f"--limit={limit}",
]
args = ["item", "history", f"--item-no={item_no}", f"--limit={limit}"]
if snapshot:
cmd.append("--snapshot")
if mode:
cmd.extend(["--mode", mode])
if env_file:
cmd.extend(["--env-file", env_file])
if endpoint:
cmd.extend(["--endpoint", endpoint])
args.append("--snapshot")
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:
return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd}
# Try JSON first
@@ -546,9 +561,9 @@ def try_item_history(
def enrich_with_history(
payload: dict[str, Any],
*,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
) -> dict[str, Any]:
"""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:
return payload
hist = try_item_history(
str(item_no),
str(item_no[0] if isinstance(item_no, list) else item_no),
mode=mode,
env_file=env_file,
nmi_bin=nmi_bin,
@@ -574,9 +589,9 @@ def try_auto_link_redmine_contact(
tracking: list[str] | None = None,
order_id: list[str] | None = None,
item_no: list[str] | None = None,
mode: str = DEFAULT_MODE,
env_file: str = DEFAULT_ENV_FILE,
nmi_bin: str = DEFAULT_NMI_BIN,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
) -> dict[str, Any]:
"""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.
@@ -602,13 +617,10 @@ def try_auto_link_redmine_contact(
if not ids:
return {"ok": False, "reason": "no_identifiers"}
cmd = [nmi_bin, "link-contact", "--redmine-contact-id", str(contact_id), "--mode", mode]
if env_file and env_file != DEFAULT_ENV_FILE:
cmd.extend(["--env-file", env_file])
cmd.extend(ids)
args = ["link-contact", "--redmine-contact-id", str(contact_id), *ids]
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
result = {
"ok": ok,
+2 -2
View File
@@ -989,8 +989,8 @@ class RedmineTextualTUI(App):
except Exception:
pass
mode = os.environ.get("NMI_MODE", "dev")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE")
if ch.entity_extract and issue and extract_entities_from_issue:
try:
+4 -4
View File
@@ -442,16 +442,16 @@ class RedmineTUI:
except Exception:
pass
mode = os.environ.get("NMI_MODE", "dev")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE")
runtime_profile = (
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
)
if runtime_profile is not None:
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:
try:
+61 -20
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import argparse
import json
import copy
from redmine_cache_io import CacheError, cache_lock, read_json, atomic_write_json, update_manifest_atomic
import os
import re
import sys
@@ -1062,26 +1064,45 @@ class ManifestTicketStateStore:
self.project = project
self.manifest_path = cache_dir / "manifest.json"
self.manifest = self.load_manifest()
self._baseline = copy.deepcopy(self.manifest)
def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file():
return {"version": 1, "tickets": {}}
with self.manifest_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
data = read_json(self.manifest_path)
data.setdefault("version", 1)
data.setdefault("tickets", {})
return data
def save(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
with self.manifest_path.open("w", encoding="utf-8") as handle:
json.dump(self.manifest, handle, indent=2, sort_keys=True)
handle.write("\n")
# Apply only locally changed leaves to current disk state. This preserves
# updates made by another long-lived cache instance since our last read.
def delta(current, before, after):
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:
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:
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.setdefault("project", self.project)
ticket_entry.setdefault("actions", {})
@@ -1099,6 +1120,9 @@ class ManifestTicketStateStore:
result: str,
metadata: dict[str, Any] | 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.setdefault("project", self.project)
actions = ticket_entry.setdefault("actions", {})
@@ -1146,10 +1170,11 @@ class TicketCache:
def write_issue(self, issue: dict[str, Any]) -> None:
issue_id = issue["id"]
self.project_dir.mkdir(parents=True, exist_ok=True)
with self.issue_path(issue_id).open("w", encoding="utf-8") as handle:
json.dump(issue, handle, indent=2, sort_keys=True)
handle.write("\n")
# Use one lock for a record + manifest transaction. Alternate stores keep
# their own state transaction semantics, but JSON still writes atomically.
lock_target = self.manifest_path or (self.cache_dir / "manifest.json")
with cache_lock(lock_target):
atomic_write_json(self.issue_path(issue_id), issue)
self.state_store.upsert_ticket_state(
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:
api_key = os.getenv("REDMINE_API_KEY")
if not api_key and getattr(args, "api_key_file", None):
key_path = Path(args.api_key_file)
if key_path.is_file():
if getattr(args, "api_key_file", None):
key_path = Path(args.api_key_file).expanduser()
try:
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:
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)
@@ -3265,7 +3294,7 @@ KNOWN_COMMANDS = frozenset(
# Parent-parser options that take a following value. Used when injecting the
# 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]:
@@ -3311,6 +3340,16 @@ def main(argv: list[str] | None = None) -> int:
normalized_argv = inject_default_sync_command(raw_argv)
parser = build_parser()
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
# (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"handoff_hint=contact-show --contact-id {cid}")
print(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer")
print(
"handoff_hint_full_crm="
f"redmine_contacts.py --base-url {args.base_url} --project {args.project} "
f"show --contact-id {cid}"
)
from redmine_cli_runtime import contacts_handoff
print("handoff_hint_full_crm=" + contacts_handoff(args, cid))
# --fetch implies show; alone it used to silently do nothing (bug).
want_show = bool(getattr(args, "show", False) or 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__":
try:
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
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.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(
environ={
+1 -1
View File
@@ -224,7 +224,7 @@ class RedmineTicketsSyncTest(unittest.TestCase):
self.assertEqual(2, result.seen)
self.assertEqual(1, result.skipped_cached)
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())