Compare commits
6 Commits
9ee9eb3ec6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 57314dea52 | |||
| 7ad3723525 | |||
| 4e10a21142 | |||
| 71bb541d0b | |||
| 2e686215ed | |||
| f6915face2 |
+14
@@ -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
|
||||
+4886
File diff suppressed because it is too large
Load Diff
Executable
+5
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
|
||||
Executable
+5
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"base-url": "http://192.168.50.170",
|
||||
"project": "customer-service",
|
||||
"cache-dir": "/tmp/redmine/cache-dev/redmine_tickets"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
+42
-24
@@ -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,28 +499,29 @@ def main() -> int:
|
||||
|
||||
try:
|
||||
if args.command == "fetch":
|
||||
client = client_from_args(args)
|
||||
contacts = client.fetch_contacts(limit=args.limit)
|
||||
filtered_contacts = filter_contacts_from_args(contacts, args)
|
||||
cache_path = resolve_cache_path(args)
|
||||
effective_cache_mode = resolve_fetch_cache_mode(args)
|
||||
with cache_lock(resolve_cache_path(args)):
|
||||
client = client_from_args(args)
|
||||
contacts = client.fetch_contacts(limit=args.limit)
|
||||
filtered_contacts = filter_contacts_from_args(contacts, args)
|
||||
cache_path = resolve_cache_path(args)
|
||||
effective_cache_mode = resolve_fetch_cache_mode(args)
|
||||
|
||||
cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts
|
||||
write_cache(cache_path, args, cache_contacts)
|
||||
cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts
|
||||
write_cache(cache_path, args, cache_contacts)
|
||||
|
||||
if effective_cache_mode == "filtered":
|
||||
print(
|
||||
f"Cached {len(filtered_contacts)} filtered contacts "
|
||||
f"(from {len(contacts)} fetched) in {cache_path}"
|
||||
)
|
||||
elif has_contact_filters(args):
|
||||
print(
|
||||
f"Cached {len(contacts)} contacts in {cache_path}. "
|
||||
f"{len(filtered_contacts)} contacts currently match the supplied filters."
|
||||
)
|
||||
else:
|
||||
print(f"Cached {len(contacts)} contacts in {cache_path}")
|
||||
return 0
|
||||
if effective_cache_mode == "filtered":
|
||||
print(
|
||||
f"Cached {len(filtered_contacts)} filtered contacts "
|
||||
f"(from {len(contacts)} fetched) in {cache_path}"
|
||||
)
|
||||
elif has_contact_filters(args):
|
||||
print(
|
||||
f"Cached {len(contacts)} contacts in {cache_path}. "
|
||||
f"{len(filtered_contacts)} contacts currently match the supplied filters."
|
||||
)
|
||||
else:
|
||||
print(f"Cached {len(contacts)} contacts in {cache_path}")
|
||||
return 0
|
||||
|
||||
if args.command == "search":
|
||||
contacts = load_or_refresh(args)
|
||||
@@ -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:
|
||||
|
||||
+20
-25
@@ -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,22 +58,23 @@ class ContactCache:
|
||||
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
|
||||
|
||||
def write_contact(self, contact: dict[str, Any]) -> 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")
|
||||
manifest = self.load_manifest()
|
||||
contacts = manifest.setdefault("contacts", {})
|
||||
contacts[str(int(cid))] = {
|
||||
"id": int(cid),
|
||||
"updated_on": contact.get("updated_on"),
|
||||
"last_synced": now_iso(),
|
||||
"email": primary_email(contact),
|
||||
"name": contact_display_name(contact),
|
||||
}
|
||||
self.save_manifest(manifest)
|
||||
return 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))
|
||||
atomic_write_json(path, contact)
|
||||
manifest = self.load_manifest()
|
||||
contacts = manifest.setdefault("contacts", {})
|
||||
contacts[str(int(cid))] = {
|
||||
"id": int(cid),
|
||||
"updated_on": contact.get("updated_on"),
|
||||
"last_synced": now_iso(),
|
||||
"email": primary_email(contact),
|
||||
"name": contact_display_name(contact),
|
||||
}
|
||||
self.save_manifest(manifest)
|
||||
return path
|
||||
|
||||
def load_contact(self, contact_id: int) -> dict[str, Any]:
|
||||
path = self.contact_path(contact_id)
|
||||
|
||||
+170
-158
@@ -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 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}",
|
||||
)
|
||||
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}",
|
||||
)
|
||||
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 failed(f"NMI env file does not exist: {selected_env_file}")
|
||||
if selected_env_file.stat().st_size == 0:
|
||||
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 NmiRuntimeProfile(
|
||||
mode=normalized_mode,
|
||||
env_file=selected_env_file,
|
||||
nmi_bin=selected_nmi_bin,
|
||||
endpoint=selected_endpoint,
|
||||
ready=False,
|
||||
problem=f"NMI CLI does not exist: {selected_nmi_bin}",
|
||||
)
|
||||
|
||||
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=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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+85
-44
@@ -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,31 +1064,50 @@ 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:
|
||||
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
|
||||
ticket_entry.setdefault("project", self.project)
|
||||
ticket_entry.setdefault("actions", {})
|
||||
ticket_entry.update(fields)
|
||||
self.save()
|
||||
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", {})
|
||||
ticket_entry.update(fields)
|
||||
self.save()
|
||||
|
||||
def has_action(self, issue_id: int | str, action_name: str) -> bool:
|
||||
entry = self.get_ticket_state(issue_id) or {}
|
||||
@@ -1099,15 +1120,18 @@ class ManifestTicketStateStore:
|
||||
result: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
|
||||
ticket_entry.setdefault("project", self.project)
|
||||
actions = ticket_entry.setdefault("actions", {})
|
||||
actions[action_name] = {
|
||||
"last_run": now_iso(),
|
||||
"result": result,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self.save()
|
||||
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", {})
|
||||
actions[action_name] = {
|
||||
"last_run": now_iso(),
|
||||
"result": result,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self.save()
|
||||
|
||||
|
||||
class TicketCache:
|
||||
@@ -1146,19 +1170,20 @@ 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")
|
||||
self.state_store.upsert_ticket_state(
|
||||
issue_id,
|
||||
{
|
||||
"project": self.project,
|
||||
"updated_on": issue.get("updated_on"),
|
||||
"last_synced": now_iso(),
|
||||
"path": str(self.issue_path(issue_id)),
|
||||
},
|
||||
)
|
||||
# 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,
|
||||
{
|
||||
"project": self.project,
|
||||
"updated_on": issue.get("updated_on"),
|
||||
"last_synced": now_iso(),
|
||||
"path": str(self.issue_path(issue_id)),
|
||||
},
|
||||
)
|
||||
|
||||
def iter_cached_issues(self) -> Iterable[dict[str, Any]]:
|
||||
if not self.project_dir.is_dir():
|
||||
@@ -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__":
|
||||
raise SystemExit(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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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}]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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={
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user