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

4929 lines
197 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Incremental Redmine issue sync/search foundation.
This is the ticket-centric companion to redmine_contacts.py. It caches enriched
Redmine issue payloads locally using the standard Redmine issue API with the
local Helpdesk plugin's `include=helpdesk` support.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Iterable
from redmine_contacts import (
DEFAULT_BASE_URL,
DEFAULT_PROJECT,
RedmineClient,
RedmineError,
extract_tag_list,
merge_tags,
)
try:
from redmine_ticket_query import QueryError, filter_by_query
except ImportError: # pragma: no cover
QueryError = ValueError # type: ignore[misc, assignment]
filter_by_query = None # type: ignore[assignment]
try:
from redmine_ticket_view import display_markdown_file, render_ticket_markdown, write_ticket_markdown
except ImportError: # pragma: no cover
display_markdown_file = None # type: ignore[assignment]
render_ticket_markdown = None # type: ignore[assignment]
write_ticket_markdown = None # type: ignore[assignment]
try:
from redmine_ticket_filters import (
DEFAULT_SAVED_FILTERS_DIR,
expand_where_list,
load_queue_definitions,
materialize_queue,
)
except ImportError: # pragma: no cover
DEFAULT_SAVED_FILTERS_DIR = Path("saved-filters") # type: ignore[misc, assignment]
expand_where_list = None # type: ignore[assignment]
load_queue_definitions = None # type: ignore[assignment]
materialize_queue = None # type: ignore[assignment]
try:
from redmine_ticket_drafts import (
KIND_CONTACT_FIELDS,
KIND_CUSTOMER_EMAIL,
KIND_FIELDS,
KIND_INTERNAL_NOTE,
add_draft,
assert_write_target_allowed,
cancel_all_pending,
cancel_draft,
kind_label,
list_drafts,
load_draft,
mark_draft_applied,
mark_draft_error,
normalize_kind,
)
except ImportError: # pragma: no cover
KIND_CONTACT_FIELDS = "contact_fields" # type: ignore[misc, assignment]
KIND_CUSTOMER_EMAIL = "customer_email" # type: ignore[misc, assignment]
KIND_FIELDS = "fields" # type: ignore[misc, assignment]
KIND_INTERNAL_NOTE = "internal_note" # type: ignore[misc, assignment]
add_draft = None # type: ignore[assignment]
assert_write_target_allowed = None # type: ignore[assignment]
cancel_all_pending = None # type: ignore[assignment]
cancel_draft = None # type: ignore[assignment]
kind_label = None # type: ignore[assignment]
list_drafts = None # type: ignore[assignment]
load_draft = None # type: ignore[assignment]
mark_draft_applied = None # type: ignore[assignment]
mark_draft_error = None # type: ignore[assignment]
normalize_kind = None # type: ignore[assignment]
try:
from redmine_ticket_control_tags import (
add_tag as control_tag_add,
annotate_issues_with_control_tags,
filter_issues_by_control_tag,
list_tags as control_tag_list,
remove_tag as control_tag_remove,
tags_for_issue,
tickets_for_tag,
wipe_all as control_tag_wipe,
)
except ImportError: # pragma: no cover
control_tag_add = None # type: ignore[assignment]
annotate_issues_with_control_tags = None # type: ignore[assignment]
filter_issues_by_control_tag = None # type: ignore[assignment]
control_tag_list = None # type: ignore[assignment]
control_tag_remove = None # type: ignore[assignment]
tags_for_issue = None # type: ignore[assignment]
tickets_for_tag = None # type: ignore[assignment]
control_tag_wipe = None # type: ignore[assignment]
# Lazy import to avoid circular dependency with redmine_triage
add_triage_parser = None
def _get_add_triage_parser():
global add_triage_parser
if add_triage_parser is None:
try:
from redmine_triage import add_triage_parser as _atp
add_triage_parser = _atp
except Exception:
add_triage_parser = None
return add_triage_parser
try:
from redmine_ticket_meta import refresh_meta_cache, resolve_field_names
except ImportError: # pragma: no cover
refresh_meta_cache = None # type: ignore[assignment]
resolve_field_names = None # type: ignore[assignment]
try:
from redmine_ticket_contacts import (
ContactCache,
contact_display_name,
contact_id_from_issue,
filter_contacts,
primary_email,
sync_contacts,
)
except ImportError: # pragma: no cover
ContactCache = None # type: ignore[misc, assignment]
contact_display_name = None # type: ignore[assignment]
contact_id_from_issue = None # type: ignore[assignment]
filter_contacts = None # type: ignore[assignment]
primary_email = None # type: ignore[assignment]
sync_contacts = None # type: ignore[assignment]
try:
from redmine_ticket_attachments import (
attachment_dir,
attachments_from_issue,
download_attachment_bytes,
download_url_for_attachment,
pick_attachment,
safe_filename,
upload_file_token,
)
except ImportError: # pragma: no cover
attachment_dir = None # type: ignore[assignment]
attachments_from_issue = None # type: ignore[assignment]
download_attachment_bytes = None # type: ignore[assignment]
download_url_for_attachment = None # type: ignore[assignment]
pick_attachment = None # type: ignore[assignment]
safe_filename = None # type: ignore[assignment]
upload_file_token = None # type: ignore[assignment]
try:
from redmine_ticket_entities import (
extract_entities_from_issue,
format_entities_line,
)
from redmine_ticket_nmi import brief_for_issue, format_private_note
except ImportError: # pragma: no cover
extract_entities_from_issue = None # type: ignore[assignment]
format_entities_line = None # type: ignore[assignment]
brief_for_issue = None # type: ignore[assignment]
format_private_note = None # type: ignore[assignment]
try:
from redmine_ticket_control_export import export_control_tag_jsonl
except ImportError: # pragma: no cover
export_control_tag_jsonl = None # type: ignore[assignment]
try:
from redmine_ticket_browse import announce_draft, prompt_multiline, run_browse_loop
except ImportError: # pragma: no cover
announce_draft = None # type: ignore[assignment]
prompt_multiline = None # type: ignore[assignment]
run_browse_loop = None # type: ignore[assignment]
try:
from redmine_ticket_tui import add_tui_parser, main_cli as run_tui_cli
except ImportError: # pragma: no cover
add_tui_parser = None # type: ignore[assignment]
run_tui_cli = None # type: ignore[assignment]
try:
from redmine_ticket_textual_tui import (
add_textual_tui_parser,
run_textual_tui_cli,
)
except ImportError: # pragma: no cover
add_textual_tui_parser = None # type: ignore[assignment]
run_textual_tui_cli = None # type: ignore[assignment]
try:
from redmine_ticket_ocr import (
attachment_is_ocr_candidate,
build_ocr_note,
extract_text_from_file,
looks_like_generic_scan_subject,
suggest_subject_from_text,
)
except ImportError: # pragma: no cover
attachment_is_ocr_candidate = None # type: ignore[assignment]
build_ocr_note = None # type: ignore[assignment]
extract_text_from_file = None # type: ignore[assignment]
looks_like_generic_scan_subject = None # type: ignore[assignment]
suggest_subject_from_text = None # type: ignore[assignment]
# Lazy import to avoid circular dependency with redmine_triage
add_triage_parser = None
def _get_add_triage_parser():
global add_triage_parser
if add_triage_parser is None:
try:
from redmine_triage import add_triage_parser as _atp
add_triage_parser = _atp
except Exception:
add_triage_parser = None
return add_triage_parser
try:
from redmine_ticket_meta import fetch_project_categories, load_meta_lists
except ImportError: # pragma: no cover
fetch_project_categories = None # type: ignore[assignment]
load_meta_lists = None # type: ignore[assignment]
DEFAULT_CACHE_DIR = Path(".cache/redmine_tickets")
ORDER_CHOICES = (
"as-is",
"oldest-updated-first",
"newest-updated-first",
"id-asc",
"id-desc",
)
SENDER_SPAM_HINTS = re.compile(
r"\b(no-?reply|noreply|deals?|promotions?|marketing|newsletter|offers?|sales?)\b",
re.IGNORECASE,
)
PROMO_LANGUAGE = re.compile(
r"\b(discount|coupon|deal|promo|sale|offer|free|save|% off|limited time|up to \d+% off)\b",
re.IGNORECASE,
)
SALES_PITCH_LANGUAGE = re.compile(
r"\b(commercial cleaning|janitorial|cleaning services|we would like to offer|trying to reach|lead generation|marketing services|advertis(?:e|ing)|staffing services|outsourcing|managed services)\b",
re.IGNORECASE,
)
AUTOMATED_OPERATIONAL = re.compile(
r"\b(fba inventory|unfulfillable fba|inventory removal|listings api|amazon seller|payment status|shipment status|tracking update|invoice|return notification|ereceipt|e-?receipt|money order|undelivered mail|returned to sender|message could not be delivered|delivery status notification|critical security alert|security alert|saved passwords|passwords were found|google account|call transcription|voicemail|new message in mailbox|message transcription|voip\.ms|voipinterface)\b",
re.IGNORECASE,
)
INBOUND_BUSINESS_INQUIRY = re.compile(
r"\b(we(?:'| a)?re interested in purchasing|interested in purchasing|do you have any available|already working with a buyer|still have the capacity to support|workflow we previously discussed|ready to activate|returns workflow we previously discussed|do you process|rate sheet|capabilities and pricing)\b",
re.IGNORECASE,
)
VAULT_ONYX_PREFIX = "https://onyx.int.ldrprep.com/projects/obsidian-ldr/"
ANALYSIS_MODES = ("deterministic", "llm")
# =============================================================================
# Known Spam Profile System (Fast Path)
# =============================================================================
def load_known_spam_profiles(path: Path) -> dict[str, Any]:
"""Load a known-spam profile JSON file."""
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
data.setdefault("universal_safety_gates", {})
data.setdefault("profiles", [])
return data
def _compile_profile_regexes(profile: dict[str, Any]) -> dict[str, list[re.Pattern]]:
"""Compile sender and subject regexes for a single profile."""
compiled = {"sender": [], "subject": []}
for pattern in profile.get("sender_regexes", []):
compiled["sender"].append(re.compile(pattern, re.IGNORECASE))
for pattern in profile.get("subject_regexes", []):
compiled["subject"].append(re.compile(pattern, re.IGNORECASE))
return compiled
def match_known_spam_profile(issue: dict[str, Any], profile: dict[str, Any], compiled: dict[str, list[re.Pattern]]) -> bool:
"""Return True if the issue matches the given profile."""
from_addr = ticket_from_address(issue)
subject = str(issue.get("subject") or "")
sender_match = any(p.search(from_addr) for p in compiled["sender"])
subject_match = any(p.search(subject) for p in compiled["subject"])
if compiled["sender"] and compiled["subject"]:
return sender_match and subject_match
return sender_match or subject_match
def get_known_spam_matches(issue: dict[str, Any], profiles: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return list of matching profiles for an issue."""
matches = []
for profile in profiles:
compiled = _compile_profile_regexes(profile)
if match_known_spam_profile(issue, profile, compiled):
matches.append(profile)
return matches
def ticket_was_reopened_after_close(issue: dict[str, Any]) -> bool:
"""Check journals for evidence the ticket was closed then reopened by a new message."""
for journal in issue.get("journals", []) or []:
details = journal.get("details") or []
for detail in details:
if detail.get("property") == "attr" and detail.get("name") == "status_id":
old = str(detail.get("old_value") or "")
new = str(detail.get("new_value") or "")
if old in ("5", "Closed", "Resolved") and new not in ("5", "Closed", "Resolved"):
return True
return False
def should_skip_known_spam_close(issue: dict[str, Any], client: Any, safety_gates: dict[str, Any]) -> tuple[bool, str]:
"""Return (should_skip, reason)."""
status_name = (nested_name(issue, "status") or "").lower()
if status_name in ("closed", "resolved"):
return True, "already_closed"
contact_id = ticket_contact_id(issue)
if contact_id:
contact = client.get_contact(contact_id)
tag_list = [t.lower() for t in extract_tag_list(contact)]
for forbidden in safety_gates.get("skip_contact_tags", ["customer", "maybe-customer"]):
if forbidden.lower() in tag_list:
return True, f"contact_has_{forbidden.lower()}_tag"
if ticket_was_reopened_after_close(issue):
return True, "reopened_after_close"
return False, ""
def run_known_spam_scan(
cache: TicketCache,
profiles_path: Path,
max_tickets: int | None = None,
) -> list[dict[str, Any]]:
"""Scan cached issues and return matches (dry-run)."""
profile_data = load_known_spam_profiles(profiles_path)
profiles = profile_data.get("profiles", [])
results = []
issues = list(cache.iter_cached_issues())
if max_tickets:
issues = issues[:max_tickets]
for issue in issues:
matches = get_known_spam_matches(issue, profiles)
if matches:
results.append({
"issue_id": issue.get("id"),
"subject": issue.get("subject"),
"from": ticket_from_address(issue),
"contact_id": ticket_contact_id(issue),
"matched_profiles": [p["name"] for p in matches],
})
return results
def run_known_spam_close(
client: Any,
cache: TicketCache,
profiles_path: Path,
close_status_id: int = 5,
apply: bool = False,
max_tickets: int | None = None,
) -> list[dict[str, Any]]:
"""Close issues matching known spam profiles."""
profile_data = load_known_spam_profiles(profiles_path)
profiles = profile_data.get("profiles", [])
safety_gates = profile_data.get("universal_safety_gates", {})
impersonate = safety_gates.get("write_impersonate_user", "rebot")
plans = []
issues = list(cache.iter_cached_issues())
if max_tickets:
issues = issues[:max_tickets]
for issue in issues:
matches = get_known_spam_matches(issue, profiles)
if not matches:
continue
issue_id = issue.get("id")
if issue_id is None:
continue
skip, reason = should_skip_known_spam_close(issue, client, safety_gates)
if skip:
if apply:
cache.record_action(issue_id, "known-spam-close", f"skipped:{reason}")
continue
if cache.has_action(issue_id, "known-spam-close"):
continue
payload = {
"status_id": close_status_id,
"notes": f"Closed as known promotional spam. Profiles: {', '.join(p['name'] for p in matches)}",
}
plan = {
"issue_id": issue_id,
"subject": issue.get("subject"),
"from": ticket_from_address(issue),
"matched_profiles": [p["name"] for p in matches],
"payload": payload,
}
plans.append(plan)
if apply:
client.update_issue(int(issue_id), payload, impersonate_user=impersonate)
cache.record_action(issue_id, "known-spam-close", "closed", {"profiles": [p["name"] for p in matches]})
return plans
# =============================================================================
# End Known Spam Profile System
# =============================================================================
INBOUND_BUSINESS_INQUIRY = re.compile(
r"\b(we(?:'| a)?re interested in purchasing|interested in purchasing|do you have any available|already working with a buyer|still have the capacity to support|workflow we previously discussed|ready to activate|returns workflow we previously discussed|do you process|rate sheet|capabilities and pricing)\b",
re.IGNORECASE,
)
VAULT_ONYX_PREFIX = "https://onyx.int.ldrprep.com/projects/obsidian-ldr/"
def slugify_note(value: str) -> str:
text = value.strip().lower()
text = re.sub(r"[’']", "", text)
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-") or "untitled"
def first_sentence(value: str, max_len: int = 240) -> str:
text = re.sub(r"\s+", " ", value or "").strip()
if not text:
return ""
match = re.search(r"(?<=[.!?])\s+", text)
sentence = text[: match.start()] if match else text
if len(sentence) > max_len:
sentence = sentence[: max_len - 1].rstrip() + "…"
return sentence
def parse_dt(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
if "T" not in text and len(text) == 10:
text = text + "T00:00:00+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
_RELATIVE_TIME_RE = re.compile(
r"^(?P<n>\d+)\s*(?P<unit>d|day|days|h|hour|hours|m|min|mins|minute|minutes|w|week|weeks)\s*(?P<ago>ago)?$",
re.IGNORECASE,
)
_REMOTE_OPERATOR_PREFIXES = (">=", "<=", "><", ">", "<", "=")
def format_redmine_time(dt: datetime, *, date_only: bool = False) -> str:
"""Format a datetime for Redmine REST date filters."""
utc = dt.astimezone(timezone.utc).replace(microsecond=0)
if date_only:
return utc.date().isoformat()
return utc.isoformat().replace("+00:00", "Z")
def coerce_time_value(value: str, *, now: datetime | None = None) -> tuple[datetime, bool]:
"""Parse absolute or relative time into (datetime, is_date_only).
Accepts:
- YYYY-MM-DD
- ISO datetime (with optional Z)
- relative: 30d, 2h, 15m, 1w, '2 days ago', '30 days ago'
"""
text = (value or "").strip()
if not text:
raise RedmineError("Empty time value.")
relative = _RELATIVE_TIME_RE.fullmatch(text)
if relative:
amount = int(relative.group("n"))
unit = relative.group("unit").lower()
anchor = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
if unit.startswith("w"):
delta = amount * 7 * 24 * 3600
elif unit.startswith("d"):
delta = amount * 24 * 3600
elif unit.startswith("h"):
delta = amount * 3600
else:
delta = amount * 60
return anchor - timedelta(seconds=delta), False
# Pure calendar date → date-only Redmine filter (start-of-day semantics on server).
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
dt = parse_dt(text)
if dt is None:
raise RedmineError(f"Could not parse date: {value!r}")
return dt, True
dt = parse_dt(text)
if dt is None:
raise RedmineError(
f"Could not parse time value {value!r}. "
"Use YYYY-MM-DD, ISO datetime, or relative forms like 30d / '2 days ago'."
)
return dt, False
def build_remote_updated_on_value(
raw: str | None = None,
since: str | None = None,
before: str | None = None,
*,
now: datetime | None = None,
) -> str | None:
"""Build Redmine `updated_on` API filter value.
Raw form (official REST syntax) wins if provided:
>=2014-01-02T08:12:32Z
<=2012-03-07
><2012-03-01|2012-03-07
Convenience flags map to the same operators:
since only → >=...
before only → <=...
both → ><since|before
"""
raw_text = (raw or "").strip()
since_text = (since or "").strip() or None
before_text = (before or "").strip() or None
if raw_text:
if since_text or before_text:
raise RedmineError(
"Use either --remote-updated-on (raw Redmine syntax) "
"or --remote-updated-since/--remote-updated-before, not both."
)
# Allow bare timestamps by implying >= (common cron mistake).
if not raw_text.startswith(_REMOTE_OPERATOR_PREFIXES):
return f">={raw_text}"
return raw_text
if not since_text and not before_text:
return None
since_part = None
before_part = None
if since_text:
dt, date_only = coerce_time_value(since_text, now=now)
since_part = format_redmine_time(dt, date_only=date_only)
if before_text:
dt, date_only = coerce_time_value(before_text, now=now)
before_part = format_redmine_time(dt, date_only=date_only)
if since_part and before_part:
return f"><{since_part}|{before_part}"
if since_part:
return f">={since_part}"
return f"<={before_part}"
def nested_name(issue: dict[str, Any], key: str) -> str | None:
value = issue.get(key)
if isinstance(value, dict):
name = value.get("name")
return str(name) if name is not None else None
if value is None:
return None
return str(value)
def ticket_contact(issue: dict[str, Any]) -> dict[str, Any]:
helpdesk = issue.get("helpdesk_ticket") or {}
contact = helpdesk.get("contact") or {}
return contact if isinstance(contact, dict) else {}
def ticket_from_address(issue: dict[str, Any]) -> str:
helpdesk = issue.get("helpdesk_ticket") or {}
return str(helpdesk.get("from_address") or ticket_contact(issue).get("email") or "")
def issue_text_for_decomposition(issue: dict[str, Any]) -> str:
parts = [str(issue.get("subject") or ""), str(issue.get("description") or "")]
for journal in issue.get("journals", []) or []:
notes = str(journal.get("notes") or "").strip()
if notes:
parts.append(notes)
return "\n".join(parts)
def clean_message_block(text: str) -> str:
lines: list[str] = []
for raw_line in (text or "").replace("\r", "\n").split("\n"):
line = raw_line.strip()
if not line:
continue
if re.match(r"^(from|subject|to|date|phone):\s", line, re.IGNORECASE):
continue
if line.lower() == "message body:":
continue
if re.match(r"^-{5,}\s*forwarded message\s*-{5,}$", line, re.IGNORECASE):
continue
if line == "--" or line.startswith("-- "):
break
if line.startswith(">"):
continue
if "this e-mail was sent from a contact form" in line.lower():
continue
if re.match(r"^on .+ wrote:$", line, re.IGNORECASE):
break
if line.startswith("<https://") or line.startswith("<http://"):
continue
lines.append(line)
return " ".join(lines)
def clean_ticket_text(issue: dict[str, Any]) -> str:
"""Return a compact source text suitable for decomposition prompts/plans."""
parts: list[str] = []
description = clean_message_block(str(issue.get("description") or ""))
if description:
parts.append(description)
for journal in issue.get("journals", []) or []:
notes = clean_message_block(str(journal.get("notes") or ""))
if notes:
user = (journal.get("user") or {}).get("name") if isinstance(journal.get("user"), dict) else None
prefix = f"{user}: " if user else ""
parts.append(prefix + notes)
return "\n".join(dict.fromkeys(parts))
def extract_decomposition_analysis(issue: dict[str, Any], org_name: str, buckets: list[str], tags: list[str]) -> dict[str, Any]:
"""Deterministic KarpathyWiki-style structured analysis for the first dry-run slice.
This intentionally mirrors the plugin's source-analysis shape without calling an LLM yet.
"""
text = clean_ticket_text(issue)
text_l = text.lower()
entities = [
{
"name": org_name,
"type": "organization",
"summary": f"Organization mentioned in Redmine #{issue.get('id')}.",
"mentions_in_source": [org_name],
"related_entities": [],
"related_concepts": [],
}
]
concepts: list[dict[str, Any]] = []
key_points: list[str] = []
summary = first_sentence(text) or str(issue.get("subject") or "")
if "gaylord" in text_l:
summary = f"{org_name} asked whether LDR has used Gaylord boxes available to purchase."
key_points.extend([
"The sender wants to buy used Gaylord boxes from LDR, not sell LDR a generic service.",
"The inquiry may represent a buy-from-us or liquidation-adjacent opportunity.",
])
concepts.append({"name": "buy-from-us inquiry", "type": "term", "summary": "An inbound request to buy goods or assets from LDR."})
if "fulfillment" in text_l or "returns workflow" in text_l or "fba returns" in text_l or "warehousing" in text_l:
summary = f"{org_name} raised a fulfillment/returns opportunity with LDR."
key_points.append("The ticket concerns warehousing, fulfillment, returns management, or FBA returns services.")
concepts.append({"name": "returns management", "type": "field", "summary": "Operational handling of returned goods and related customer workflows."})
if "rate sheet" in text_l or "calculator" in text_l:
summary = f"{org_name} asked for a returns rate sheet after the pricing/calculator path did not answer their needs."
key_points.extend([
"The contact reported that the linked calculator did not calculate what they needed.",
"The contact asked for a rate sheet, exposing a possible returns-pricing collateral gap.",
])
concepts.append({"name": "rate-sheet gap", "type": "term", "summary": "A sales-collateral gap where pricing must be explained ad hoc instead of through a reusable artifact."})
if not key_points and text:
key_points.append(first_sentence(text, 300))
# Deduplicate concepts by normalized name.
deduped_concepts: list[dict[str, Any]] = []
seen: set[str] = set()
for concept in concepts:
key = slugify_note(str(concept.get("name", "")))
if key and key not in seen:
seen.add(key)
deduped_concepts.append(concept)
return {
"source_title": str(issue.get("subject") or ""),
"summary": summary,
"entities": entities,
"concepts": deduped_concepts,
"key_points": list(dict.fromkeys(key_points)),
"contradictions": [],
"related_pages": [],
"clean_text": text,
}
def issue_event_date(issue: dict[str, Any]) -> str:
journal_dates = []
for journal in issue.get("journals", []) or []:
if str(journal.get("notes") or "").strip():
dt = parse_dt(journal.get("created_on"))
if dt:
journal_dates.append(dt)
if journal_dates:
return max(journal_dates).date().isoformat()
dt = parse_dt(issue.get("created_on") or issue.get("updated_on")) or datetime.now(timezone.utc)
return dt.date().isoformat()
def infer_organization_name(issue: dict[str, Any]) -> str:
subject = str(issue.get("subject") or "")
sender = ticket_from_address(issue).lower()
text = issue_text_for_decomposition(issue).lower()
if "repackify" in subject.lower() or "repackify" in sender or "repackify" in text:
return "RePackify"
if "u2wear" in subject.lower() or "u2wear" in sender or "u2wear" in text or "elora" in text:
return "u2wear"
if "snugglepuppy" in sender or "snuggle puppy" in text or "snugglepuppy" in text:
return "Snuggle Puppy"
contact = ticket_contact(issue)
for key in ("company", "name", "email"):
value = str(contact.get(key) or "").strip()
if value:
if "@" in value:
value = value.split("@", 1)[1].split(".", 1)[0]
return value
return sender.split("@", 1)[-1].split(".", 1)[0] if sender else "Unknown Organization"
def classify_decomposition_buckets(issue: dict[str, Any]) -> tuple[list[str], list[str]]:
text = clean_ticket_text(issue).lower()
buckets: list[str] = []
tags: list[str] = ["redmine"]
if any(term in text for term in ("interested in purchasing", "gaylord", "already working with a buyer")):
buckets.append("partnership-buy-from-us")
tags.extend(["partnership-opportunity", "buy-from-us", "gaylords"])
if any(term in text for term in ("fulfillment", "returns workflow", "fba returns", "warehousing")):
buckets.extend(["customer-new-project", "meaningful-event"])
tags.extend(["customer-inquiry", "returns-management"])
if "rate sheet" in text or "calculator" in text or "pricing" in text:
buckets.append("meaningful-event")
tags.extend(["pricing", "rate-sheet-gap"])
if not buckets:
buckets.append("meaningful-event")
return list(dict.fromkeys(buckets)), list(dict.fromkeys(tags))
def decompose_issue_dry_run(issue: dict[str, Any], vault_prefix: str = VAULT_ONYX_PREFIX) -> dict[str, Any]:
"""Build a reviewable decomposition plan for one Redmine issue.
This is the first headless companion slice inspired by KarpathyWiki. It does
not write to the vault, does not update Redmine, and deliberately writes
planned paths outside the plugin-owned `wiki/` tree.
"""
issue_id = int(issue.get("id") or 0)
subject = str(issue.get("subject") or f"Redmine issue {issue_id}").strip().strip('"')
event_date = issue_event_date(issue)
ym = event_date[:7]
org_name = infer_organization_name(issue)
org_slug = slugify_note(org_name)
subject_for_slug = re.sub(re.escape(org_name), "", subject, flags=re.IGNORECASE).strip(" -_:") or subject
event_slug = slugify_note(f"{org_name} {subject_for_slug}")
timeline_rel = f"Timeline/{ym}/{event_date}_{event_slug}.md"
entity_rel = f"entities/organizations/{org_slug}.md"
buckets, tags = classify_decomposition_buckets(issue)
contact = ticket_contact(issue)
contact_id = contact.get("id")
text = issue_text_for_decomposition(issue)
from_address = ticket_from_address(issue)
analysis = extract_decomposition_analysis(issue, org_name, buckets, tags)
summary = analysis["summary"]
confidence = "high" if "customer-new-project" in buckets or "rate-sheet-gap" in tags else "medium"
suggested_contact_tags = [tag for tag in tags if tag not in {"redmine", "customer-inquiry"}]
if "customer-new-project" in buckets and "maybe-customer" not in suggested_contact_tags:
suggested_contact_tags.insert(0, "maybe-customer")
return {
"schema_version": 1,
"mode": "dry-run",
"source": {
"source_type": "redmine-ticket",
"redmine_issue_id": issue_id,
"redmine_url": f"https://rm.ldrprep.com/issues/{issue_id}" if issue_id else None,
"title": subject,
"date": event_date,
"sender": from_address,
"redmine_contact_id": contact_id,
},
"buckets": buckets,
"tags": tags,
"confidence": confidence,
"analysis": analysis,
"review_required": True,
"timeline_events": [
{
"path": timeline_rel,
"onyx_url": vault_prefix + timeline_rel,
"title": f"{event_date} — {org_name} {subject}",
"summary": summary,
"entity_links": [f"[[{entity_rel[:-3]}|{org_name}]]"],
"dialectic_sections": ["claim", "supporting_evidence", "counterpoints", "decision_outcome", "follow_up_candidates"],
}
],
"entities": [
{
"type": "organization",
"name": org_name,
"path": entity_rel,
"onyx_url": vault_prefix + entity_rel,
"redmine_contact_id": contact_id,
"aliases": [org_name],
"suggested_tags": suggested_contact_tags,
"source_analysis": analysis["entities"][0] if analysis.get("entities") else {},
}
],
"concepts": analysis.get("concepts", []),
"redmine_contact_updates": [
{
"contact_id": contact_id,
"add_tags": suggested_contact_tags,
"obsidian_article_url": vault_prefix + entity_rel,
"apply": False,
}
] if contact_id else [],
"evidence": {
"from": from_address,
"subject": subject,
"excerpt": first_sentence(analysis.get("clean_text", ""), 500),
},
"compatibility_notes": [
"Dry-run only: no vault files or Redmine contacts are modified.",
"Do not write into plugin-owned wiki/ by default; use Timeline/ and entities/organizations/.",
"Use explicit wikilinks if referencing plugin pages such as [[wiki/concepts/returns-management|returns management]].",
],
}
def filter_issues(
issues: Iterable[dict[str, Any]],
updated_since: str | None = None,
updated_before: str | None = None,
status: str | None = None,
category: str | None = None,
) -> list[dict[str, Any]]:
since_dt = parse_dt(updated_since)
before_dt = parse_dt(updated_before)
status_l = status.lower() if status else None
category_l = category.lower() if category else None
filtered: list[dict[str, Any]] = []
for issue in issues:
updated_dt = parse_dt(issue.get("updated_on"))
if since_dt and (not updated_dt or updated_dt < since_dt):
continue
if before_dt and updated_dt and updated_dt > before_dt:
continue
if status_l:
issue_status = (nested_name(issue, "status") or "").lower()
if issue_status != status_l:
continue
if category_l:
issue_category = (nested_name(issue, "category") or "").lower()
if issue_category != category_l:
continue
filtered.append(issue)
return filtered
def select_issues(
issues: Iterable[dict[str, Any]],
order: str = "as-is",
max_tickets: int | None = None,
) -> list[dict[str, Any]]:
selected = list(issues)
if order == "oldest-updated-first":
selected.sort(key=lambda issue: parse_dt(issue.get("updated_on")) or datetime.min.replace(tzinfo=timezone.utc))
elif order == "newest-updated-first":
selected.sort(key=lambda issue: parse_dt(issue.get("updated_on")) or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
elif order == "id-asc":
selected.sort(key=lambda issue: int(issue.get("id", 0)))
elif order == "id-desc":
selected.sort(key=lambda issue: int(issue.get("id", 0)), reverse=True)
elif order != "as-is":
raise ValueError(f"Unsupported order: {order}")
if max_tickets is not None:
selected = selected[:max_tickets]
return selected
@dataclass
class SyncResult:
seen: int = 0
fetched: int = 0
skipped_cached: int = 0
written: int = 0
DEFAULT_RUN_LOG_KEEP = 3
DEFAULT_RUN_LOG_NAME = "run-stats.json"
def resolve_run_log_path(raw: str | None, cache_dir: Path) -> Path | None:
"""Return the run-log path when enabled, else None.
CLI uses nargs='?' with const='__default__' so:
--run-log → <cache-dir>/run-stats.json
--run-log /path/file.json → /path/file.json
(omitted) → None
"""
if raw is None:
return None
if raw in {"", "__default__"}:
return Path(cache_dir) / DEFAULT_RUN_LOG_NAME
return Path(raw)
def load_run_log(path: Path) -> dict[str, Any]:
if not path.is_file():
return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []}
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []}
if not isinstance(data, dict):
return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []}
data.setdefault("version", 1)
data.setdefault("keep", DEFAULT_RUN_LOG_KEEP)
runs = data.get("runs")
if not isinstance(runs, list):
data["runs"] = []
return data
def append_run_stats(
path: Path,
entry: dict[str, Any],
*,
keep: int = DEFAULT_RUN_LOG_KEEP,
) -> Path:
"""Append one run entry and retain only the newest `keep` runs.
Designed for cron: fixed small file, no rotation tooling required.
Newest run is last in the list.
"""
if keep < 1:
keep = DEFAULT_RUN_LOG_KEEP
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
data = load_run_log(path)
runs = [r for r in data.get("runs", []) if isinstance(r, dict)]
runs.append(entry)
data["version"] = 1
data["keep"] = keep
data["runs"] = runs[-keep:]
data["updated_at"] = now_iso()
tmp_path = path.with_suffix(path.suffix + ".tmp")
with tmp_path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
tmp_path.replace(path)
return path
def build_sync_run_entry(
*,
started_at: str,
finished_at: str,
duration_seconds: float,
args: argparse.Namespace,
remote_updated_on: str | None,
result: SyncResult | None,
ok: bool,
error: str | None = None,
) -> dict[str, Any]:
return {
"started_at": started_at,
"finished_at": finished_at,
"duration_seconds": round(duration_seconds, 3),
"command": "sync",
"ok": ok,
"error": error,
"base_url": getattr(args, "base_url", None),
"project": getattr(args, "project", None),
"cache_dir": str(getattr(args, "cache_dir", "")),
"dry_run": bool(getattr(args, "dry_run", False)),
"filters": {
"remote_status_id": getattr(args, "status_id", None),
"remote_updated_on": remote_updated_on,
"remote_updated_since": getattr(args, "remote_updated_since", None),
"remote_updated_before": getattr(args, "remote_updated_before", None),
"local_updated_since": getattr(args, "updated_since", None),
"local_updated_before": getattr(args, "updated_before", None),
"local_status": getattr(args, "status", None),
"local_category": getattr(args, "category", None),
"max_tickets": getattr(args, "max_tickets", None),
"order": getattr(args, "order", None),
},
"stats": asdict(result) if result is not None else None,
}
@dataclass
class SpamCheckResult:
issue_id: int
score: int
is_spam: bool
subject: str
from_address: str
reasons: list[str]
category: str = "unknown-review"
@dataclass
class CloseIfSpamPlan:
issue_id: int
payload: dict[str, Any]
contact_id: int | None
contact_review: dict[str, Any]
subject: str
class ManifestTicketStateStore:
"""Manifest-backed ticket state store.
This is intentionally a thin implementation behind the TicketCache state-store
boundary. A future SQLite store should only need to implement the same small
method set used here.
"""
def __init__(self, cache_dir: Path, project: str):
self.cache_dir = cache_dir
self.project = project
self.manifest_path = cache_dir / "manifest.json"
self.manifest = self.load_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.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")
def get_ticket_state(self, issue_id: int | str) -> dict[str, Any] | None:
return self.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()
def has_action(self, issue_id: int | str, action_name: str) -> bool:
entry = self.get_ticket_state(issue_id) or {}
return action_name in entry.get("actions", {})
def record_action(
self,
issue_id: int | str,
action_name: str,
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()
class TicketCache:
def __init__(self, cache_dir: Path, project: str, state_store: Any | None = None):
self.cache_dir = cache_dir
self.project = project
self.project_dir = cache_dir / project
self.state_store = state_store or ManifestTicketStateStore(cache_dir, project)
@property
def manifest(self) -> dict[str, Any]:
return getattr(self.state_store, "manifest", {"version": 1, "tickets": {}})
@property
def manifest_path(self) -> Path | None:
return getattr(self.state_store, "manifest_path", None)
def issue_path(self, issue_id: int | str) -> Path:
return self.project_dir / f"{issue_id}.json"
def get_entry(self, issue_id: int | str) -> dict[str, Any] | None:
return self.state_store.get_ticket_state(issue_id)
def should_fetch(self, issue_stub: dict[str, Any]) -> bool:
issue_id = issue_stub.get("id")
if issue_id is None:
return True
entry = self.get_entry(issue_id)
if not entry:
return True
if not self.issue_path(issue_id).is_file():
return True
cached_updated = entry.get("updated_on")
current_updated = issue_stub.get("updated_on")
return bool(current_updated and cached_updated != current_updated)
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)),
},
)
def iter_cached_issues(self) -> Iterable[dict[str, Any]]:
if not self.project_dir.is_dir():
return []
issues: list[dict[str, Any]] = []
for path in sorted(self.project_dir.glob("*.json")):
with path.open("r", encoding="utf-8") as handle:
issues.append(json.load(handle))
return issues
def has_action(self, issue_id: int | str, action_name: str) -> bool:
return self.state_store.has_action(issue_id, action_name)
def record_action(
self,
issue_id: int | str,
action_name: str,
result: str,
metadata: dict[str, Any] | None = None,
) -> None:
self.state_store.record_action(issue_id, action_name, result, metadata)
class TicketRedmineClient(RedmineClient):
def list_issues(
self,
limit: int = 100,
status_id: str = "*",
include: str | None = None,
extra_params: dict[str, Any] | None = None,
progress: bool = False,
) -> list[dict[str, Any]]:
"""List issue stubs from Redmine with server-side filters.
`status_id` and any keys in `extra_params` (e.g. updated_on, sort) are
sent as Redmine REST filters. Do not put heavy includes on the list
path when you plan to fetch full issues one-by-one.
"""
issues: list[dict[str, Any]] = []
offset = 0
total = None
page_num = 0
while total is None or offset < total:
params: dict[str, Any] = {
"limit": limit,
"offset": offset,
"status_id": status_id,
}
if include:
params["include"] = include
if extra_params:
params.update(extra_params)
data = self.request("GET", f"/projects/{self.project}/issues.json", params)
page = data.get("issues", [])
issues.extend(page)
total = int(data.get("total_count", len(issues)))
received = len(page)
page_num += 1
if progress:
print(
f" list page {page_num}: +{received} "
f"(collected={len(issues)}/{total}, offset={offset})",
flush=True,
)
if received == 0:
break
offset += received
return issues
def get_issue_with_includes(self, issue_id: int, includes: Iterable[str]) -> dict[str, Any]:
return self.get_issue(issue_id, includes=includes)
def update_issue(self, issue_id: int, fields: dict[str, Any], impersonate_user: str | None = None) -> None:
self.request(
"PUT",
f"/issues/{issue_id}.json",
payload={"issue": fields},
impersonate_user=impersonate_user,
)
def add_internal_note(
self,
issue_id: int,
notes: str,
*,
private: bool = False,
impersonate_user: str | None = None,
) -> None:
"""Add a Redmine journal note. Does NOT send Helpdesk/customer email."""
text = (notes or "").strip()
if not text:
raise RedmineError("Internal note body is required.")
fields: dict[str, Any] = {"notes": text}
if private:
fields["private_notes"] = True
self.update_issue(issue_id, fields, impersonate_user=impersonate_user)
def send_customer_helpdesk_email(
self,
issue_id: int,
content: str,
*,
status_id: int | None = None,
to_address: str | None = None,
cc_address: str | None = None,
bcc_address: str | None = None,
impersonate_user: str | None = None,
) -> dict[str, Any]:
"""Send a customer-visible Helpdesk email via POST /helpdesk/email_note.
This is the RedmineUP Helpdesk "send note" path used by redMCP.
It emails the customer. Do not use for internal staff notes.
"""
text = (content or "").strip()
if not text:
raise RedmineError("Customer email body is required.")
message: dict[str, Any] = {"issue_id": int(issue_id), "content": text}
if status_id is not None:
message["status_id"] = int(status_id)
payload: dict[str, Any] = {"message": message}
if to_address:
payload["to_address"] = to_address
if cc_address:
payload["cc_address"] = cc_address
if bcc_address:
payload["bcc_address"] = bcc_address
return self.request(
"POST",
"/helpdesk/email_note.json",
payload=payload,
impersonate_user=impersonate_user,
)
def get_issue_attachments(self, issue_id: int) -> list[dict[str, Any]]:
issue = self.get_issue_with_includes(int(issue_id), ("attachments", "journals", "helpdesk"))
atts = issue.get("attachments")
return list(atts) if isinstance(atts, list) else []
def attach_upload_to_issue(
self,
issue_id: int,
upload: dict[str, Any],
*,
notes: str | None = None,
impersonate_user: str | None = None,
) -> None:
"""Attach a previously uploaded token to an issue (does not email)."""
token = upload.get("token")
if not token:
raise RedmineError("Upload token missing.")
entry: dict[str, Any] = {
"token": token,
"filename": upload.get("filename") or "attachment.bin",
"content_type": upload.get("content_type") or "application/octet-stream",
}
if upload.get("description"):
entry["description"] = upload["description"]
fields: dict[str, Any] = {"uploads": [entry]}
if notes:
fields["notes"] = notes
self.update_issue(int(issue_id), fields, impersonate_user=impersonate_user)
def recent_issues_for_contact(self, contact_id: int, limit: int) -> list[dict[str, Any]]:
response = self.helpdesk_issues_by_contact(contact_id, limit)
summaries = response.get("issues", []) if isinstance(response, dict) else []
issues: list[dict[str, Any]] = []
for summary in summaries[:limit]:
issue_id = summary.get("issue_id") or summary.get("id")
if issue_id is None:
continue
issues.append(self.get_issue_with_includes(int(issue_id), ("journals", "helpdesk")))
return issues
def sync_tickets(
client: Any,
cache: TicketCache,
updated_since: str | None = None,
updated_before: str | None = None,
status: str | None = None,
category: str | None = None,
max_tickets: int | None = None,
order: str = "as-is",
apply: bool = True,
include_journals: bool = True,
page_size: int = 100,
status_id: str = "*",
remote_updated_on: str | None = None,
progress: bool = True,
) -> SyncResult:
"""Incrementally sync tickets.
Server-side (Redmine API) filters:
- status_id
- updated_on (via remote_updated_on, official operators)
Local post-filters still available via updated_since/updated_before/status/category.
Per-ticket should_fetch skips full downloads when cache already has same updated_on.
"""
extra_params: dict[str, Any] = {}
if remote_updated_on:
extra_params["updated_on"] = remote_updated_on
# Prefer newest first on the wire so --max-tickets is meaningful under remote filters.
if order == "newest-updated-first":
extra_params["sort"] = "updated_on:desc"
elif order == "oldest-updated-first":
extra_params["sort"] = "updated_on"
elif order == "id-desc":
extra_params["sort"] = "id:desc"
elif order == "id-asc":
extra_params["sort"] = "id"
if progress:
print(f"Cache dir: {cache.cache_dir}", flush=True)
print(f"Manifest: {cache.manifest_path}", flush=True)
print(f"Project: {cache.project}", flush=True)
print(
f"Remote API filters: status_id={status_id!r} updated_on={remote_updated_on!r}",
flush=True,
)
if updated_since or updated_before or status or category:
print(
"Local post-filters: "
f"updated_since={updated_since!r} updated_before={updated_before!r} "
f"status={status!r} category={category!r}",
flush=True,
)
print("Listing issue stubs from Redmine...", flush=True)
stubs = client.list_issues(
limit=page_size,
status_id=status_id,
include=None,
extra_params=extra_params or None,
progress=progress,
)
if progress:
print(f"Listed {len(stubs)} stub(s) from API.", flush=True)
selected = filter_issues(
stubs,
updated_since=updated_since,
updated_before=updated_before,
status=status,
category=category,
)
selected = select_issues(selected, order=order, max_tickets=max_tickets)
includes = ("journals", "helpdesk", "attachments") if include_journals else ("helpdesk", "attachments")
result = SyncResult(seen=len(selected))
if progress:
print(f"Selected {len(selected)} ticket(s) after local filters/order/max.", flush=True)
print("Fetching full tickets (skipping unchanged cache entries)...", flush=True)
for index, stub in enumerate(selected, start=1):
issue_id = stub.get("id")
if issue_id is None:
continue
if not cache.should_fetch(stub):
result.skipped_cached += 1
if progress and (index == 1 or index == len(selected) or index % 25 == 0):
print(
f" [{index}/{len(selected)}] #{issue_id} cache-skip "
f"(fetched={result.fetched} skipped={result.skipped_cached} written={result.written})",
flush=True,
)
continue
full_issue = client.get_issue_with_includes(int(issue_id), includes)
result.fetched += 1
if apply:
cache.write_issue(full_issue)
result.written += 1
if progress and (index == 1 or index == len(selected) or index % 10 == 0 or result.fetched <= 3):
print(
f" [{index}/{len(selected)}] #{issue_id} fetched "
f"(fetched={result.fetched} skipped={result.skipped_cached} written={result.written})",
flush=True,
)
return result
def spam_check_text(issue: dict[str, Any]) -> str:
parts = [str(issue.get("subject", "")), str(issue.get("description", ""))]
helpdesk = issue.get("helpdesk_ticket") or {}
if isinstance(helpdesk, dict):
parts.append(str(helpdesk.get("from_address", "")))
contact = helpdesk.get("contact") or {}
if isinstance(contact, dict):
parts.append(str(contact.get("email", "")))
parts.append(str(contact.get("name", "")))
parts.append(str(contact.get("company", "")))
for journal in issue.get("journals", [])[:5]:
if isinstance(journal, dict):
user = journal.get("user") or {}
user_name = str(user.get("name", "")) if isinstance(user, dict) else ""
if user_name and user_name.lower() != "anonymous":
continue
parts.append(str(journal.get("notes", ""))[:1000])
return "\n".join(parts)
def score_ticket_spam(issue: dict[str, Any], threshold: int = 2) -> SpamCheckResult:
text = spam_check_text(issue)
subject = str(issue.get("subject", ""))
helpdesk = issue.get("helpdesk_ticket") or {}
from_address = ""
if isinstance(helpdesk, dict):
from_address = str(helpdesk.get("from_address", ""))
reasons: list[str] = []
score = 0
text_l = text.lower()
if AUTOMATED_OPERATIONAL.search(text):
return SpamCheckResult(
issue_id=int(issue.get("id", 0)),
score=0,
is_spam=False,
subject=subject,
from_address=from_address,
reasons=["automated operational notification"],
category="automated-operational",
)
if INBOUND_BUSINESS_INQUIRY.search(text):
return SpamCheckResult(
issue_id=int(issue.get("id", 0)),
score=0,
is_spam=False,
subject=subject,
from_address=from_address,
reasons=["inbound or established business inquiry"],
category="unknown-review",
)
if SENDER_SPAM_HINTS.search(from_address):
score += 1
reasons.append("sender has promotional/automated hint")
if PROMO_LANGUAGE.search(subject):
score += 2
reasons.append("subject contains promotional language")
elif PROMO_LANGUAGE.search(text):
score += 1
reasons.append("body/journal contains promotional language")
if SALES_PITCH_LANGUAGE.search(text):
score += 2
reasons.append("ticket contains unsolicited sales pitch language")
if SENDER_SPAM_HINTS.search(text):
score += 1
reasons.append("ticket text contains promotional sender hint")
is_spam = score >= threshold
category = "spam-promotional" if is_spam else "unknown-review"
return SpamCheckResult(
issue_id=int(issue.get("id", 0)),
score=score,
is_spam=is_spam,
subject=subject,
from_address=from_address,
reasons=reasons,
category=category,
)
def run_spam_check(cache: TicketCache, apply: bool = False, threshold: int = 2) -> list[SpamCheckResult]:
results: list[SpamCheckResult] = []
for issue in cache.iter_cached_issues():
issue_id = issue.get("id")
if issue_id is None or cache.has_action(issue_id, "spam-check"):
continue
result = score_ticket_spam(issue, threshold=threshold)
results.append(result)
if apply:
cache.record_action(
issue_id,
"spam-check",
"spam" if result.is_spam else "not-spam",
{
"score": result.score,
"category": result.category,
"subject": result.subject,
"from_address": result.from_address,
"reasons": result.reasons,
},
)
return results
def print_spam_results(results: Iterable[SpamCheckResult]) -> None:
for result in results:
verdict = result.category if result.is_spam else result.category
reasons = "; ".join(result.reasons) if result.reasons else "no spam signals"
print(
f"#{result.issue_id}\t{verdict}\tscore={result.score}\t"
f"from={result.from_address}\t{result.subject}\t{reasons}"
)
def ticket_contact_id(issue: dict[str, Any]) -> int | None:
helpdesk = issue.get("helpdesk_ticket") or {}
if not isinstance(helpdesk, dict):
return None
contact = helpdesk.get("contact") or {}
if isinstance(contact, dict) and contact.get("id") is not None:
return int(contact["id"])
if helpdesk.get("contact_id") is not None:
return int(helpdesk["contact_id"])
return None
def action_result(cache: TicketCache, issue_id: int | str, action_name: str) -> str | None:
entry = cache.get_entry(issue_id) or {}
action = entry.get("actions", {}).get(action_name) or {}
result = action.get("result")
return str(result) if result is not None else None
def action_category(cache: TicketCache, issue_id: int | str, action_name: str) -> str | None:
entry = cache.get_entry(issue_id) or {}
action = entry.get("actions", {}).get(action_name) or {}
metadata = action.get("metadata", {})
if not isinstance(metadata, dict):
metadata = {}
category = metadata.get("category") or action.get("result")
return str(category) if category is not None else None
def action_metadata(cache: TicketCache, issue_id: int | str, action_name: str) -> dict[str, Any]:
entry = cache.get_entry(issue_id) or {}
action = entry.get("actions", {}).get(action_name) or {}
metadata = action.get("metadata", {})
return metadata if isinstance(metadata, dict) else {}
def contact_has_tag(contact: dict[str, Any], tag: str) -> bool:
wanted = tag.strip().lower()
return wanted in {existing.strip().lower() for existing in extract_tag_list(contact)}
def contact_is_customer(client: Any, contact_id: int | None) -> bool:
if contact_id is None:
return False
contact = client.get_contact(contact_id)
return contact_has_tag(contact, "customer")
def analyze_contact_spam(issues: Iterable[dict[str, Any]], threshold: int = 2) -> dict[str, Any]:
scored = [score_ticket_spam(issue, threshold=threshold) for issue in issues]
spam_ids = [result.issue_id for result in scored if result.is_spam]
total = len(scored)
likely_spammer = bool(total and (len(spam_ids) >= 2 or len(spam_ids) / total >= 0.5))
return {
"checked": total,
"spam_count": len(spam_ids),
"spam_issue_ids": spam_ids,
"likely_spammer": likely_spammer,
}
def build_close_payload(close_status_id: int, notes: str | None = None) -> dict[str, Any]:
return {
"status_id": close_status_id,
"notes": notes or "Closed as likely spam by redmine_tickets.py close-if-spam.",
}
def spam_check_reasons(cache: TicketCache, issue_id: int | str) -> list[str]:
metadata = action_metadata(cache, issue_id, "spam-check")
reasons = metadata.get("reasons", [])
return [str(reason) for reason in reasons] if isinstance(reasons, list) else []
def run_tag_maybe_customer_contacts(
client: Any,
cache: TicketCache,
apply: bool = False,
tag_name: str = "maybe-customer",
impersonate_user: str = "rebot",
) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
seen_contacts: set[int] = set()
for issue in cache.iter_cached_issues():
issue_id = issue.get("id")
if issue_id is None:
continue
reasons = spam_check_reasons(cache, issue_id)
if "inbound or established business inquiry" not in reasons:
continue
contact_id = ticket_contact_id(issue)
if contact_id is None or contact_id in seen_contacts:
continue
seen_contacts.add(contact_id)
contact = client.get_contact(contact_id)
if contact_has_tag(contact, "customer") or contact_has_tag(contact, tag_name):
continue
new_tags = merge_tags(extract_tag_list(contact), [tag_name], [])
result = {"contact_id": contact_id, "tag_list": new_tags, "source_issue_id": int(issue_id)}
results.append(result)
if apply:
client.update_contact(contact_id, {"tag_list": new_tags}, impersonate_user=impersonate_user)
cache.record_action(
issue_id,
"tag-maybe-customer",
"tagged",
{"contact_id": contact_id, "tag": tag_name},
)
return results
def run_close_if_spam(
client: Any,
cache: TicketCache,
close_status_id: int,
contact_ticket_limit: int = 10,
apply: bool = False,
threshold: int = 2,
impersonate_user: str = "rebot",
close_categories: tuple[str, ...] = ("spam-promotional", "spam"),
) -> list[CloseIfSpamPlan]:
plans: list[CloseIfSpamPlan] = []
for issue in cache.iter_cached_issues():
issue_id = issue.get("id")
if issue_id is None:
continue
if action_category(cache, issue_id, "spam-check") not in close_categories:
continue
if cache.has_action(issue_id, "close-if-spam"):
continue
contact_id = ticket_contact_id(issue)
if contact_is_customer(client, contact_id):
if apply:
cache.record_action(
issue_id,
"close-if-spam",
"skipped-customer-contact",
{"contact_id": contact_id},
)
continue
recent_issues = client.recent_issues_for_contact(contact_id, contact_ticket_limit) if contact_id else []
contact_review = analyze_contact_spam(recent_issues, threshold=threshold)
payload = build_close_payload(close_status_id)
plan = CloseIfSpamPlan(
issue_id=int(issue_id),
payload=payload,
contact_id=contact_id,
contact_review=contact_review,
subject=str(issue.get("subject", "")),
)
plans.append(plan)
if apply:
client.update_issue(int(issue_id), payload, impersonate_user=impersonate_user)
cache.record_action(
issue_id,
"close-if-spam",
"closed",
{"payload": payload, "contact_id": contact_id},
)
cache.record_action(
issue_id,
"contact-spam-review",
"likely-spammer" if contact_review.get("likely_spammer") else "single-ticket-spam",
contact_review,
)
return plans
def print_close_if_spam_plans(plans: Iterable[CloseIfSpamPlan]) -> None:
for plan in plans:
review = plan.contact_review
print(
f"#{plan.issue_id}\tclose_payload={json.dumps(plan.payload, sort_keys=True)}\t"
f"contact_id={plan.contact_id}\tcontact_spam={review.get('spam_count')}/{review.get('checked')}\t"
f"likely_spammer={review.get('likely_spammer')}\t{plan.subject}"
)
def print_issue_rows(issues: Iterable[dict[str, Any]]) -> None:
for issue in issues:
status = nested_name(issue, "status") or ""
category = nested_name(issue, "category") or ""
assignee = nested_name(issue, "assigned_to") or ""
contact = ""
helpdesk_raw = issue.get("helpdesk_ticket")
helpdesk = helpdesk_raw if isinstance(helpdesk_raw, dict) else {}
c = helpdesk.get("contact") if helpdesk else None
if isinstance(c, dict):
contact = str(c.get("email") or c.get("name") or c.get("company") or "")
elif helpdesk.get("from_address"):
contact = str(helpdesk.get("from_address"))
tags = issue.get("_control_tags")
tag_s = ",".join(tags) if isinstance(tags, list) and tags else ""
subject = str(issue.get("subject", "")).replace("\n", " ")
print(
f"#{issue.get('id')}\t{issue.get('updated_on', '')}\t{status}\t"
f"{assignee}\t{contact}\t{category}\t{tag_s}\t{subject}"
)
def load_issue_for_decompose(args: argparse.Namespace, cache: TicketCache) -> dict[str, Any]:
if args.input_json:
with Path(args.input_json).open("r", encoding="utf-8") as handle:
return json.load(handle)
if args.issue_id is None:
raise RedmineError("decompose requires --issue-id or --input-json")
cached_path = cache.issue_path(args.issue_id)
if cached_path.is_file():
with cached_path.open("r", encoding="utf-8") as handle:
return json.load(handle)
client = client_from_args(args)
return client.get_issue_with_includes(args.issue_id, ("journals", "helpdesk"))
def render_decomposition_markdown(plan: dict[str, Any]) -> str:
source = plan["source"]
lines = [
f"# Decomposition dry-run — Redmine #{source.get('redmine_issue_id')}",
"",
"## Source",
"",
f"- Redmine: {source.get('redmine_url')}",
f"- Title: {source.get('title')}",
f"- Date: {source.get('date')}",
f"- Sender: {source.get('sender')}",
f"- Contact ID: {source.get('redmine_contact_id')}",
"",
"## Buckets",
"",
]
lines.extend(f"- `{bucket}`" for bucket in plan.get("buckets", []))
analysis = plan.get("analysis", {})
lines.extend(["", "## Structured analysis", "", f"**Summary:** {analysis.get('summary', '')}", "", "**Key points:**", ""])
lines.extend(f"- {point}" for point in analysis.get("key_points", []))
concepts = plan.get("concepts", [])
if concepts:
lines.extend(["", "**Concepts:**", ""])
for concept in concepts:
lines.append(f"- `{concept.get('name')}` ({concept.get('type')}) — {concept.get('summary')}")
lines.extend(["", "## Planned timeline events", ""])
for event in plan.get("timeline_events", []):
lines.extend([
f"- `{event['path']}`",
f" - Onyx: {event['onyx_url']}",
f" - Summary: {event['summary']}",
])
lines.extend(["", "## Planned entities", ""])
for entity in plan.get("entities", []):
lines.extend([
f"- `{entity['path']}` — {entity['name']}",
f" - Onyx: {entity['onyx_url']}",
f" - Suggested tags: {', '.join(entity.get('suggested_tags', []))}",
])
lines.extend(["", "## Redmine contact update proposals", ""])
for update in plan.get("redmine_contact_updates", []):
lines.extend([
f"- Contact `{update['contact_id']}`",
f" - Add tags: {', '.join(update.get('add_tags', []))}",
f" - Obsidian article: {update.get('obsidian_article_url')}",
" - Apply: false",
])
lines.extend(["", "## Evidence excerpt", "", plan.get("evidence", {}).get("excerpt", ""), "", "## Compatibility notes", ""])
lines.extend(f"- {note}" for note in plan.get("compatibility_notes", []))
return "\n".join(lines)
def allow_prod_writes_enabled(args: argparse.Namespace) -> bool:
"""True if parent or subcommand requested prod writes."""
return bool(getattr(args, "allow_prod_writes", False))
def print_draft_queued(
path: Path,
*,
kind: str,
issue_id: int | None = None,
contact_id: int | None = None,
base_url: str | None,
emails_customer: bool = False,
) -> str:
"""Print machine- and human-friendly draft queue info. Returns draft_id."""
draft_id = path.stem
print(f"draft_id={draft_id}")
print(f"draft_path={path}")
if issue_id is not None:
print(f"issue_id={issue_id}")
if contact_id is not None:
print(f"contact_id={contact_id}")
print(f"kind={kind}")
print(f"base_url={base_url or ''}")
print(f"emails_customer={'YES' if emails_customer else 'NO'}")
if emails_customer:
print(
f"apply_hint=draft apply {draft_id} --i-understand-this-emails-the-customer"
+ (" --allow-prod-writes" if base_url and "rm.ldrprep.com" in str(base_url) else "")
)
else:
hint = f"apply_hint=draft apply {draft_id}"
if base_url and "rm.ldrprep.com" in str(base_url):
hint += " --allow-prod-writes"
print(hint)
return draft_id
def load_runtime_config(path: Path) -> dict[str, Any]:
"""Load a minimal YAML or JSON config for global CLI defaults.
Supported shapes:
{base-url, project, cache-dir, allow-prod-writes, api-key-file}
{redmine_tickets: { ... same keys ... }}
YAML requires PyYAML if available; otherwise JSON only.
"""
text = path.read_text(encoding="utf-8")
data: Any
if path.suffix.lower() in {".json"}:
data = json.loads(text)
else:
try:
import yaml # type: ignore
except ImportError as exc:
# try JSON anyway for .yaml misnamed
try:
data = json.loads(text)
except json.JSONDecodeError:
raise RedmineError(
f"Cannot load config {path}: need PyYAML for YAML, or use .json. ({exc})"
) from exc
else:
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise RedmineError(f"Config root must be a mapping: {path}")
if isinstance(data.get("redmine_tickets"), dict):
data = data["redmine_tickets"]
# normalize keys: base_url / base-url
out: dict[str, Any] = {}
for key, value in data.items():
norm = str(key).replace("_", "-").lower()
if norm in {"base-url", "project", "cache-dir", "allow-prod-writes", "api-key-file"}:
out[norm] = value
# ignore nested command sections for this minimal implementation
return out
def apply_runtime_config_to_argv(argv: list[str]) -> list[str]:
"""If --config is present, inject missing parent defaults from the file.
Does not override flags already on the command line.
"""
if "--config" not in argv:
return argv
try:
idx = argv.index("--config")
config_path = Path(argv[idx + 1])
except (ValueError, IndexError):
return argv
if not config_path.is_file():
# also try relative to script
alt = Path(__file__).resolve().parent / config_path
if alt.is_file():
config_path = alt
else:
raise RedmineError(f"Config file not found: {argv[idx + 1]}")
cfg = load_runtime_config(config_path)
# strip --config from argv for argparse (we still re-add as optional known arg)
# Keep --config for argparse to record path; inject defaults before it.
injected: list[str] = []
existing = set()
i = 0
while i < len(argv):
a = argv[i]
if a.startswith("--") and "=" not in a:
existing.add(a)
elif a.startswith("--") and "=" in a:
existing.add(a.split("=", 1)[0])
i += 1
mapping = {
"base-url": "--base-url",
"project": "--project",
"cache-dir": "--cache-dir",
"api-key-file": "--api-key-file",
}
for key, flag in mapping.items():
if key in cfg and flag not in existing:
injected.extend([flag, str(cfg[key])])
if cfg.get("allow-prod-writes") in (True, "true", "yes", 1, "1") and "--allow-prod-writes" not in existing:
injected.append("--allow-prod-writes")
# place injected globals at the front (before subcommand)
return injected + argv
def resolve_apply_base_url(args: argparse.Namespace, draft: dict[str, Any]) -> str:
"""Prefer draft-stored base_url so apply hits the host the draft was written for."""
draft_url = (draft.get("base_url") or "").strip()
cli_url = (getattr(args, "base_url", None) or "").strip()
default_url = (os.getenv("REDMINE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if draft_url:
# If user passed an explicit different --base-url, require override
cli_is_default = cli_url.rstrip("/") in {default_url.rstrip("/"), DEFAULT_BASE_URL.rstrip("/")}
# Also treat as explicit if it differs from draft
if cli_url and cli_url.rstrip("/") != draft_url.rstrip("/") and not cli_is_default:
if not getattr(args, "allow_base_url_override", False):
raise RedmineError(
f"Draft was created for base_url={draft_url!r} but CLI has --base-url={cli_url!r}. "
"Refusing mismatched apply. Re-run without overriding --base-url, "
"or pass --allow-base-url-override if intentional."
)
print(f"WARNING: applying draft to CLI base_url={cli_url} (draft was {draft_url})", file=sys.stderr)
return cli_url
return draft_url
print(
"WARNING: draft has no stored base_url (legacy); using CLI --base-url="
f"{cli_url or default_url}",
file=sys.stderr,
)
return cli_url or default_url
def resolve_body_text(args: argparse.Namespace) -> str:
body = getattr(args, "body", None)
body_file = getattr(args, "body_file", None)
if body_file:
body = Path(body_file).read_text(encoding="utf-8")
if not body or not str(body).strip():
raise RedmineError("Requires --body or --body-file with non-empty content.")
text = str(body)
if getattr(args, "to_textile", False):
text = maybe_convert_to_textile(text)
return text
def maybe_convert_to_textile(text: str) -> str:
"""If pandoc is available, convert Markdown (or plain) → Textile for Redmine journals.
Falls back to original text with a stderr warning when pandoc is missing/fails.
"""
import shutil
import subprocess
pandoc = shutil.which("pandoc")
if not pandoc:
print(
"warning: --to-textile requested but pandoc not found; sending body unchanged",
file=sys.stderr,
)
return text
try:
proc = subprocess.run(
[pandoc, "-f", "markdown", "-t", "textile"],
input=text,
text=True,
capture_output=True,
check=False,
timeout=30,
)
except Exception as exc: # noqa: BLE001
print(f"warning: pandoc failed ({exc}); sending body unchanged", file=sys.stderr)
return text
if proc.returncode != 0 or not (proc.stdout or "").strip():
err = (proc.stderr or "").strip()[:200]
print(f"warning: pandoc conversion empty/failed ({err}); sending body unchanged", file=sys.stderr)
return text
return proc.stdout
def is_closed_status(issue: dict[str, Any]) -> bool:
status = issue.get("status")
if not isinstance(status, dict):
return False
if status.get("is_closed") is True:
return True
name = str(status.get("name") or "").strip().lower()
return name in {"closed", "rejected", "resolved", "cancelled", "canceled", "done"}
def build_fields_payload_from_args(
args: argparse.Namespace,
*,
resolved_ids: dict[str, int] | None = None,
) -> dict[str, Any]:
"""Build Redmine issue field payload from set-fields CLI args.
Prefer numeric *id flags; name flags should be pre-resolved into resolved_ids.
"""
payload: dict[str, Any] = {}
resolved_ids = resolved_ids or {}
if getattr(args, "field_status_id", None) is not None:
payload["status_id"] = int(args.field_status_id)
elif "status_id" in resolved_ids:
payload["status_id"] = int(resolved_ids["status_id"])
if getattr(args, "assigned_to_id", None) is not None:
aid = int(args.assigned_to_id)
payload["assigned_to_id"] = None if aid == 0 else aid
elif "assigned_to_id" in resolved_ids:
aid = int(resolved_ids["assigned_to_id"])
payload["assigned_to_id"] = None if aid == 0 else aid
if getattr(args, "priority_id", None) is not None:
payload["priority_id"] = int(args.priority_id)
elif "priority_id" in resolved_ids:
payload["priority_id"] = int(resolved_ids["priority_id"])
if getattr(args, "category_id", None) is not None:
cid = int(args.category_id)
payload["category_id"] = None if cid == 0 else cid
elif "category_id" in resolved_ids:
cid = int(resolved_ids["category_id"])
payload["category_id"] = None if cid == 0 else cid
if getattr(args, "due_date", None):
payload["due_date"] = str(args.due_date).strip()
if getattr(args, "subject", None):
payload["subject"] = str(args.subject)
if getattr(args, "notes", None):
payload["notes"] = str(args.notes)
if getattr(args, "private", False):
payload["private_notes"] = True
if not payload:
raise RedmineError(
"set-fields requires at least one of: --status/--status-id, "
"--assigned-to/--assigned-to-id, --priority/--priority-id, "
"--category/--category-id, --due-date, --subject, --notes"
)
return payload
def resolve_set_fields_names(args: argparse.Namespace) -> dict[str, int]:
"""Resolve --status/--assigned-to/--priority/--category name flags to IDs."""
wants = any(
getattr(args, name, None)
for name in ("status_name", "assignee_name", "priority_name", "category_name")
)
if not wants:
return {}
if resolve_field_names is None:
raise RedmineError("Name resolution helpers unavailable (redmine_ticket_meta.py).")
client = None
# Refresh meta if missing — needs API
try:
client = client_from_args(args)
except Exception:
client = None
try:
return resolve_field_names(
Path(args.cache_dir),
args.project,
status_name=getattr(args, "status_name", None),
assignee_name=getattr(args, "assignee_name", None),
priority_name=getattr(args, "priority_name", None),
category_name=getattr(args, "category_name", None),
client=client,
refresh=bool(getattr(args, "refresh_meta", False)),
)
except LookupError as exc:
raise RedmineError(str(exc)) from exc
def apply_draft_to_redmine(
client: TicketRedmineClient,
draft: dict[str, Any],
*,
impersonate_user: str,
allow_customer_email: bool,
) -> dict[str, Any]:
kind = normalize_kind(str(draft.get("kind") or "")) if normalize_kind else str(draft.get("kind"))
body = str(draft.get("body") or "")
if kind == KIND_CONTACT_FIELDS:
contact_id = draft.get("contact_id")
if contact_id is None:
raise RedmineError("Contact draft missing contact_id.")
fields = draft.get("fields") if isinstance(draft.get("fields"), dict) else {}
if not fields:
raise RedmineError("Contact draft has empty fields payload.")
payload = dict(fields)
# Support TUI shorthand for contact tags (add_tag / remove_tag) by merging properly
if "add_tag" in payload:
tag = str(payload.pop("add_tag")).strip()
if tag:
try:
contact = client.get_contact(int(contact_id))
current = extract_tag_list(contact) if extract_tag_list else []
new_tags = merge_tags(current, [tag], []) if merge_tags else list(dict.fromkeys(current + [tag]))
payload = {"tag_list": new_tags}
except Exception:
payload = {"tag_list": [tag]}
if "remove_tag" in payload:
tag = str(payload.pop("remove_tag")).strip()
if tag:
try:
contact = client.get_contact(int(contact_id))
current = extract_tag_list(contact) if extract_tag_list else []
new_tags = merge_tags(current, [], [tag]) if merge_tags else [t for t in current if str(t).lower() != tag.lower()]
payload = {"tag_list": new_tags}
except Exception:
pass
client.update_contact(int(contact_id), payload, impersonate_user=impersonate_user)
return {
"kind": kind,
"contact_id": int(contact_id),
"emailed_customer": False,
"fields": fields,
}
if draft.get("issue_id") is None:
raise RedmineError(f"Draft kind {kind!r} requires issue_id.")
issue_id = int(draft["issue_id"])
if kind == KIND_INTERNAL_NOTE:
client.add_internal_note(
issue_id,
body,
private=bool(draft.get("private")),
impersonate_user=impersonate_user,
)
return {"kind": kind, "issue_id": issue_id, "emailed_customer": False}
if kind == KIND_CUSTOMER_EMAIL:
if not allow_customer_email:
raise RedmineError(
"Refusing to apply customer_email draft without "
"--i-understand-this-emails-the-customer. "
"This path SENDS email to the customer via Helpdesk."
)
result = client.send_customer_helpdesk_email(
issue_id,
body,
impersonate_user=impersonate_user,
)
return {"kind": kind, "issue_id": issue_id, "emailed_customer": True, "api_result": result}
if kind == KIND_FIELDS:
fields = draft.get("fields") if isinstance(draft.get("fields"), dict) else {}
if not fields:
raise RedmineError("Field draft has empty fields payload.")
client.update_issue(issue_id, dict(fields), impersonate_user=impersonate_user)
return {"kind": kind, "issue_id": issue_id, "emailed_customer": False, "fields": fields}
raise RedmineError(f"Cannot apply draft kind {kind!r}")
def load_cached_issue(cache: TicketCache, issue_id: int) -> dict[str, Any]:
path = cache.issue_path(issue_id)
if not path.is_file():
raise RedmineError(f"No cached ticket #{issue_id} at {path}")
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def refresh_cached_issue(
cache: "TicketCache",
client: Any,
issue_id: int,
*,
includes: list[str] | None = None,
) -> dict[str, Any] | None:
"""Fetch the current issue from Redmine and overwrite the local cache file.
Use after any field update / note / etc. so that the next
`rtl browse`, `rtl tui`, etc. see the fresh data (updated_on, journals, etc.)
instead of stale local copy.
"""
if client is None:
return None
try:
inc = includes or ["journals", "helpdesk", "attachments"]
if hasattr(client, "get_issue_with_includes"):
fresh = client.get_issue_with_includes(int(issue_id), inc)
elif hasattr(client, "get_issue"):
fresh = client.get_issue(int(issue_id), includes=inc)
else:
return None
if isinstance(fresh, dict) and fresh.get("id"):
cache.write_issue(fresh)
return fresh
except Exception:
# non-fatal; the user can always run a full sync
pass
return None
def select_cached_issues_for_cli(cache: TicketCache, args: argparse.Namespace) -> list[dict[str, Any]]:
issues = list(cache.iter_cached_issues())
matches = filter_issues(
issues,
updated_since=getattr(args, "updated_since", None),
updated_before=getattr(args, "updated_before", None),
status=getattr(args, "status", None),
category=getattr(args, "category", None),
)
where_raw = list(getattr(args, "where", None) or [])
if where_raw:
if filter_by_query is None:
raise RedmineError("Property query module redmine_ticket_query.py is unavailable.")
saved_dir = Path(getattr(args, "saved_filters_dir", None) or DEFAULT_SAVED_FILTERS_DIR)
# Prefer repo-relative saved-filters next to this script if cwd differs
script_saved = Path(__file__).resolve().parent / "saved-filters"
search_dirs = [saved_dir]
if script_saved not in search_dirs and script_saved.is_dir():
search_dirs.append(script_saved)
try:
if expand_where_list is not None:
where_exprs = expand_where_list(where_raw, search_dirs)
else:
where_exprs = where_raw
matches = filter_by_query(matches, expressions=where_exprs)
except QueryError as exc:
raise RedmineError(f"Invalid --where expression: {exc}") from exc
control_tag = getattr(args, "control_tag", None)
if control_tag:
if filter_issues_by_control_tag is None:
raise RedmineError("Control-tag helpers unavailable (redmine_ticket_control_tags.py).")
matches = filter_issues_by_control_tag(matches, Path(args.cache_dir), str(control_tag))
if annotate_issues_with_control_tags is not None:
matches = annotate_issues_with_control_tags(matches, Path(args.cache_dir))
if getattr(args, "has_attachments", False):
matches = [
i
for i in matches
if isinstance(i.get("attachments"), list) and len(i.get("attachments") or []) > 0
]
if getattr(args, "open_only", False):
matches = [i for i in matches if not is_closed_status(i)]
order = getattr(args, "order", "newest-updated-first")
max_tickets = getattr(args, "max_tickets", None)
return select_issues(matches, order=order, max_tickets=max_tickets)
def run_walk_session(issues: list[dict[str, Any]], cache: TicketCache, pager: str = "auto") -> int:
if not issues:
print("No tickets to walk.")
return 0
if render_ticket_markdown is None or write_ticket_markdown is None or display_markdown_file is None:
raise RedmineError("Ticket view helpers unavailable (redmine_ticket_view.py).")
index = 0
total = len(issues)
print(f"Walking {total} ticket(s). Commands: n/enter=next, p=prev, q=quit, r=redraw")
while True:
issue = issues[index]
issue_id = issue.get("id")
print(f"\n=== [{index + 1}/{total}] #{issue_id} ===")
md_path = write_ticket_markdown(issue, cache.cache_dir, cache.project)
display_markdown_file(md_path, pager=pager)
try:
raw = input("walk> ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return 0
if raw in {"", "n", "next", "j"}:
if index + 1 >= total:
print("End of list.")
else:
index += 1
continue
if raw in {"p", "prev", "k", "b", "back"}:
if index <= 0:
print("Start of list.")
else:
index -= 1
continue
if raw in {"q", "quit", "exit"}:
return 0
if raw in {"r", "redraw"}:
continue
print("Unknown command. Use n / p / q / r")
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():
api_key = key_path.read_text(encoding="utf-8").strip()
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)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Incrementally sync and search enriched Redmine issue payloads.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--base-url", default=os.getenv("REDMINE_BASE_URL", DEFAULT_BASE_URL))
parser.add_argument("--project", default=os.getenv("REDMINE_PROJECT", DEFAULT_PROJECT))
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
parser.add_argument("--api-key-file", type=Path, default=None, help="Path to a file containing only the Redmine API key (600 perms recommended).")
parser.add_argument(
"--allow-prod-writes",
action="store_true",
help=(
"Allow write/apply operations against non-LAN hosts (e.g. https://rm.ldrprep.com). "
"Parent-level flag so it works with: ... --allow-prod-writes draft apply <id>"
),
)
parser.add_argument(
"--config",
type=Path,
default=None,
help="Optional YAML/JSON config for base defaults (base-url, project, cache-dir, allow-prod-writes, api-key-file).",
)
subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="Incrementally download enriched issues into cache.")
add_common_filters(sync_parser)
add_remote_sync_filters(sync_parser)
sync_parser.add_argument("--page-size", type=int, default=100)
sync_parser.add_argument(
"--dry-run",
action="store_true",
help="Fetch but do not write cache files/manifest.",
)
sync_parser.add_argument(
"--no-journals",
action="store_true",
help="Request include=helpdesk only instead of journals,helpdesk on full issue fetch.",
)
sync_parser.add_argument(
"--quiet",
action="store_true",
help="Suppress per-stage progress output (final summary still prints).",
)
sync_parser.add_argument(
"--run-log",
nargs="?",
const="__default__",
default=None,
metavar="PATH",
help=(
"Record this sync's stats into a fixed small ring-buffer file "
f"(keeps last {DEFAULT_RUN_LOG_KEEP} runs). "
f"With no PATH, uses <cache-dir>/{DEFAULT_RUN_LOG_NAME}. "
"Intended for cron so you get recent stats without managing log rotation."
),
)
sync_parser.add_argument(
"--run-log-keep",
type=int,
default=DEFAULT_RUN_LOG_KEEP,
help=f"How many recent runs to keep in --run-log (default: {DEFAULT_RUN_LOG_KEEP}).",
)
search_parser = subparsers.add_parser("search", help="Search/filter already cached issues.")
atp = _get_add_triage_parser()
if atp: atp(subparsers)
add_common_filters(search_parser)
search_parser.add_argument(
"--where",
action="append",
default=[],
metavar="EXPR",
help=(
"LOCAL property query against cached ticket JSON. Repeatable (AND). "
'Examples: subject.includes("rate sheet"); '
"@unassigned or alias.helpdeskEmailMatchCompany (saved-filters/); "
'status.name == "New" AND assigned_to IS NULL.'
),
)
search_parser.add_argument(
"--saved-filters-dir",
type=Path,
default=None,
help="Directory of saved filter .expr files (default: ./saved-filters or repo saved-filters/).",
)
search_parser.add_argument("--json", action="store_true", help="Print matching issues as JSON.")
search_parser.add_argument("--jsonl", action="store_true", help="Print matching issues as JSON Lines.")
search_parser.add_argument(
"--ids-only",
action="store_true",
help="Print only matching issue ids (one per line).",
)
search_parser.add_argument(
"--has-attachments",
action="store_true",
help="Only tickets whose cached JSON includes a non-empty attachments list.",
)
search_parser.add_argument(
"--open-only",
action="store_true",
help="Exclude closed/resolved-ish statuses (is_closed or common closed names).",
)
search_parser.add_argument(
"--control-tag",
help="LOCAL control-tag filter: only tickets labeled with this local tag (not a Redmine tag).",
)
view_parser = subparsers.add_parser(
"view",
help="Render a cached ticket as Markdown and show it (glow/bat/cat).",
)
view_parser.add_argument("--issue-id", type=int, help="Redmine issue id (cache-first).")
view_parser.add_argument(
"issue_id_pos",
nargs="?",
type=int,
help="Issue id positional alternative to --issue-id.",
)
view_parser.add_argument(
"--pager",
default="auto",
help="Markdown display: auto|glow|bat|batcat|cat (default auto).",
)
view_parser.add_argument(
"--print",
dest="print_only",
action="store_true",
help="Print Markdown to stdout only (no pager).",
)
view_parser.add_argument(
"--write-md",
action="store_true",
help="Write/update Markdown under <cache-dir>/<project>/markdown/<id>.md (regen when updated_on changes).",
)
view_parser.add_argument(
"--force-md",
action="store_true",
help="Force regenerate Markdown cache even if updated_on stamp matches.",
)
walk_parser = subparsers.add_parser(
"walk",
help="Walk tickets interactively (n/p/q). MVP: --ids or same filters as search.",
)
add_common_filters(walk_parser)
walk_parser.add_argument(
"--where",
action="append",
default=[],
metavar="EXPR",
help="Same property query as search --where (repeatable AND; supports @saved aliases).",
)
walk_parser.add_argument(
"--saved-filters-dir",
type=Path,
default=None,
help="Directory of saved filter .expr files.",
)
walk_parser.add_argument(
"--ids",
help="Comma-separated issue ids to walk (overrides search selection when set).",
)
walk_parser.add_argument(
"--pager",
default="auto",
help="Markdown display: auto|glow|bat|batcat|cat (default auto).",
)
walk_parser.add_argument(
"--control-tag",
help="LOCAL control-tag filter (same as search --control-tag).",
)
walk_parser.add_argument(
"--open-only",
action="store_true",
help="Exclude closed/resolved-ish statuses.",
)
walk_parser.add_argument(
"--has-attachments",
action="store_true",
help="Only tickets with attachments metadata in cache.",
)
mq_parser = subparsers.add_parser(
"materialize-queues",
help="Refresh named queue folders of symlinks from saved filters / queue config JSON.",
)
mq_parser.add_argument(
"--config",
type=Path,
default=Path("queues.example.json"),
help="JSON queue definitions (see queues.example.json).",
)
mq_parser.add_argument(
"--queues-dir",
type=Path,
default=None,
help="Root directory for queue folders (default: <cache-dir>/queues).",
)
mq_parser.add_argument(
"--saved-filters-dir",
type=Path,
default=None,
help="Directory of saved filter .expr files.",
)
mq_parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be linked without writing symlinks.",
)
draft_parser = subparsers.add_parser(
"draft",
help="Local draft queue: list/cancel/apply staged notes or customer emails.",
)
draft_sub = draft_parser.add_subparsers(dest="draft_command")
# Legacy "draft add --kind" removed from help focus; use note / customer-email commands.
draft_add = draft_sub.add_parser(
"add",
help="(Prefer `note` / `customer-email` commands.) Queue a draft by explicit kind.",
)
draft_add.add_argument("--issue-id", type=int, required=True)
draft_add.add_argument(
"--kind",
choices=(KIND_INTERNAL_NOTE, KIND_CUSTOMER_EMAIL, "note", "helpdesk_reply"),
required=True,
help="REQUIRED explicit kind: internal_note (no email) or customer_email (emails customer).",
)
draft_add.add_argument("--body", help="Draft body text.")
draft_add.add_argument("--body-file", type=Path, help="Read body from file.")
draft_add.add_argument(
"--private",
action="store_true",
help="Only valid for internal_note. Private notes never email customers.",
)
draft_list = draft_sub.add_parser("list", help="List local drafts.")
draft_list.add_argument("--all", action="store_true", help="Include cancelled/applied, not only pending.")
draft_cancel = draft_sub.add_parser("cancel", help="Cancel a pending draft by id.")
draft_cancel.add_argument("draft_id", nargs="?", help="Draft id (or unique prefix).")
draft_cancel.add_argument("--all-pending", action="store_true", help="Cancel every pending draft.")
draft_apply = draft_sub.add_parser(
"apply",
help="Apply a pending draft to Redmine (LAN/test by default; prod needs --allow-prod-writes).",
)
draft_apply.add_argument("draft_id", help="Draft id (or unique prefix).")
draft_apply.add_argument(
"--impersonate-user",
default="rebot",
help="X-Redmine-Switch-User for writes (default: rebot).",
)
draft_apply.add_argument(
"--i-understand-this-emails-the-customer",
action="store_true",
help="REQUIRED when applying a customer_email draft. Refused otherwise.",
)
draft_apply.add_argument(
"--allow-base-url-override",
action="store_true",
help="Allow applying a draft to a different --base-url than the one stored on the draft.",
)
note_parser = subparsers.add_parser(
"note",
help="INTERNAL journal note only — does NOT email the customer. Default queues a draft.",
)
note_parser.add_argument("--issue-id", type=int, required=True)
note_parser.add_argument("--body", help="Note body text.")
note_parser.add_argument("--body-file", type=Path, help="Read body from file.")
note_parser.add_argument(
"--to-textile",
action="store_true",
help="If pandoc is installed, convert body (Markdown) to Textile before queue/apply.",
)
note_parser.add_argument(
"--private",
action="store_true",
help="Mark as private journal note (staff-only; still does not email).",
)
note_parser.add_argument(
"--apply",
action="store_true",
help="Write to Redmine now instead of only queueing a local draft.",
)
note_parser.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply (default: rebot).",
)
email_parser = subparsers.add_parser(
"customer-email",
help="CUSTOMER EMAIL via Helpdesk — WILL email the customer. Separate from `note` on purpose.",
)
email_parser.add_argument("--issue-id", type=int, required=True)
email_parser.add_argument("--body", help="Email body text.")
email_parser.add_argument("--body-file", type=Path, help="Read body from file.")
email_parser.add_argument(
"--to-textile",
action="store_true",
help="If pandoc is installed, convert body (Markdown) to Textile before queue/send.",
)
email_parser.add_argument(
"--i-understand-this-emails-the-customer",
action="store_true",
required=True,
help="MANDATORY confirmation flag. Without it the command will not even queue.",
)
email_parser.add_argument(
"--apply",
action="store_true",
help="Send via Helpdesk API now instead of only queueing a local draft.",
)
email_parser.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply (default: rebot).",
)
email_parser.add_argument("--to-address", help="Optional override To: for Helpdesk send.")
email_parser.add_argument("--cc-address", help="Optional Cc: for Helpdesk send.")
email_parser.add_argument("--status-id", type=int, help="Optional status_id when sending customer email.")
fields_parser = subparsers.add_parser(
"set-fields",
help="Update issue fields (status/assignee/priority/category/due_date). Does NOT email. Default queues a draft.",
)
fields_parser.add_argument("--issue-id", type=int, required=True)
fields_parser.add_argument(
"--status-id",
dest="field_status_id",
type=int,
help="Redmine status_id (numeric).",
)
fields_parser.add_argument(
"--status",
dest="status_name",
help="Status by name (e.g. Closed). Resolved via cached meta or live API.",
)
fields_parser.add_argument(
"--assigned-to-id",
type=int,
help="Assignee user id; use 0 to unassign.",
)
fields_parser.add_argument(
"--assigned-to",
dest="assignee_name",
help="Assignee by name/login (substring OK). Use 'unassigned' to clear.",
)
fields_parser.add_argument("--priority-id", type=int, help="Redmine priority_id.")
fields_parser.add_argument(
"--priority",
dest="priority_name",
help="Priority by name (e.g. Normal).",
)
fields_parser.add_argument(
"--category-id",
type=int,
help="Category id; use 0 to clear.",
)
fields_parser.add_argument(
"--category",
dest="category_name",
help="Category by name; use 'none' to clear.",
)
fields_parser.add_argument("--due-date", help="Due date YYYY-MM-DD.")
fields_parser.add_argument("--subject", help="Replace issue subject.")
fields_parser.add_argument(
"--notes",
help="Optional internal journal note attached to the field change (still no email).",
)
fields_parser.add_argument(
"--private",
action="store_true",
help="If --notes is set, mark that journal note private.",
)
fields_parser.add_argument(
"--apply",
action="store_true",
help="Write to Redmine now instead of only queueing a local draft.",
)
fields_parser.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply (default: rebot).",
)
fields_parser.add_argument(
"--refresh-meta",
action="store_true",
help="Force refresh of status/user/priority/category name→id cache before resolve.",
)
meta_parser = subparsers.add_parser(
"meta-refresh",
help="Refresh local name→id metadata cache (statuses, priorities, categories, project users).",
)
meta_parser.add_argument(
"--force",
action="store_true",
help="Refresh even if cache files are recent.",
)
contacts_parser = subparsers.add_parser(
"contacts-sync",
help="Sync project contacts into <cache-dir>/contacts/ (durable local mirror).",
)
contacts_parser.add_argument("--page-size", type=int, default=100)
contacts_parser.add_argument("--max-contacts", type=int, help="Cap how many contacts to process.")
contacts_parser.add_argument("--dry-run", action="store_true")
contacts_parser.add_argument("--quiet", action="store_true")
csearch_parser = subparsers.add_parser(
"contacts-search",
help="Search locally cached contacts (after contacts-sync).",
)
csearch_parser.add_argument("--query", help="Free-text match on name/email/company/tags.")
csearch_parser.add_argument("--email", help="Substring match on email.")
csearch_parser.add_argument("--company", help="Substring match on company.")
csearch_parser.add_argument("--tag", help="Match Redmine contact tag name.")
csearch_parser.add_argument("--max-contacts", type=int, default=50)
csearch_parser.add_argument("--ids-only", action="store_true")
csearch_parser.add_argument("--json", action="store_true")
cshow_parser = subparsers.add_parser(
"contact-show",
help="Show one cached contact (cache-first; optional live fetch).",
)
cshow_parser.add_argument("--contact-id", type=int, required=True)
cshow_parser.add_argument(
"--fetch",
action="store_true",
help="If missing from cache, fetch from Redmine and write cache.",
)
cshow_parser.add_argument("--json", action="store_true", help="Print full JSON.")
cset_parser = subparsers.add_parser(
"contact-set",
help="Update a CRM contact (tags/company/etc). Does NOT email. Default queues a draft.",
)
cset_parser.add_argument("--contact-id", type=int, required=True)
cset_parser.add_argument(
"--add-tag",
action="append",
default=[],
help="Redmine CRM tag to add (repeatable). Not a local control-tag.",
)
cset_parser.add_argument(
"--remove-tag",
action="append",
default=[],
help="Redmine CRM tag to remove (repeatable).",
)
cset_parser.add_argument("--company", help="Set company string.")
cset_parser.add_argument("--first-name", dest="first_name", help="Set first name.")
cset_parser.add_argument("--last-name", dest="last_name", help="Set last name.")
cset_parser.add_argument(
"--set",
action="append",
default=[],
dest="contact_sets",
metavar="KEY=VALUE",
help="Raw contact field assignment (e.g. job_title=Buyer). Repeatable.",
)
cset_parser.add_argument(
"--apply",
action="store_true",
help="Write to Redmine now instead of only queueing a local draft.",
)
cset_parser.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply (default: rebot).",
)
cset_parser.add_argument(
"--refresh-cache",
action="store_true",
help="After apply, re-fetch contact into local contacts cache.",
)
tcontact_parser = subparsers.add_parser(
"ticket-contact",
help="Bridge: resolve helpdesk contact id from a cached ticket (handoff to contact-*).",
)
tcontact_parser.add_argument("--issue-id", type=int, required=True)
tcontact_parser.add_argument(
"--show",
action="store_true",
help="Also print contact name/email/company after resolving id (local contacts cache).",
)
tcontact_parser.add_argument(
"--fetch",
action="store_true",
help="Fetch/refresh contact from Redmine into local cache and show it (implies --show).",
)
tcontact_parser.add_argument("--json", action="store_true", help="Emit JSON with issue/contact ids.")
att_parser = subparsers.add_parser(
"attachments",
help="List/download/upload ticket file attachments (does NOT email customer).",
)
att_sub = att_parser.add_subparsers(dest="attachments_command")
att_list = att_sub.add_parser("list", help="List attachments on a ticket (cache-first; --refresh for live).")
att_list.add_argument("--issue-id", type=int, required=True)
att_list.add_argument(
"--refresh",
action="store_true",
help="Fetch issue with include=attachments from Redmine and update local cache entry.",
)
att_list.add_argument("--json", action="store_true")
att_dl = att_sub.add_parser("download", help="Download one or all attachments for a ticket.")
att_dl.add_argument("--issue-id", type=int, required=True)
att_dl.add_argument("--attachment-id", type=int, help="Specific attachment id (required if multiple unless --all).")
att_dl.add_argument("--all", action="store_true", help="Download every attachment on the issue.")
att_dl.add_argument(
"--out-dir",
type=Path,
default=None,
help="Destination dir (default: <cache>/<project>/attachments/<issue_id>/).",
)
att_dl.add_argument(
"--refresh",
action="store_true",
help="Refresh attachment metadata from Redmine first.",
)
att_up = att_sub.add_parser(
"upload",
help="Upload a local file and attach it to a ticket (does NOT email). Requires --apply to write.",
)
att_up.add_argument("--issue-id", type=int, required=True)
att_up.add_argument("--file", type=Path, required=True, help="Local file path to upload.")
att_up.add_argument("--filename", help="Override filename sent to Redmine.")
att_up.add_argument("--description", default="", help="Attachment description.")
att_up.add_argument("--notes", help="Optional internal journal note with the attach (no email).")
att_up.add_argument(
"--apply",
action="store_true",
help="Actually upload+attach. Without this, only validates the local file path.",
)
att_up.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply (default: rebot).",
)
ent_parser = subparsers.add_parser(
"entities",
help="Extract tracking/order/SKU-like entities from tickets (pattern-based; optional NMI enrich).",
)
ent_sub = ent_parser.add_subparsers(dest="entities_command")
ent_one = ent_sub.add_parser("show", help="Extract entities from one ticket (cache).")
ent_one.add_argument("--issue-id", type=int, required=True)
ent_one.add_argument("--json", action="store_true")
ent_scan = ent_sub.add_parser("scan", help="Scan search results / cache for entity hits.")
ent_scan.add_argument("--where", action="append", default=[], help="Local property filter (repeatable AND).")
ent_scan.add_argument("--type", dest="entity_type", help="Only this entity type (e.g. tracking_ups).")
ent_scan.add_argument("--max-tickets", type=int, default=50)
ent_scan.add_argument("--ids-only", action="store_true", help="Print only issue ids that had hits.")
ent_scan.add_argument("--jsonl", action="store_true", help="One JSON object per hit line.")
ent_nmi = ent_sub.add_parser(
"nmi",
help="Resolve entities against NMI (bin/nmi) and print cs-brief / structured plan.",
)
ent_nmi.add_argument("--issue-id", type=int, required=True)
ent_nmi.add_argument(
"--format",
choices=["brief", "json", "note"],
default="brief",
help="brief=cs-brief text; json=full payload; note=private-note body for Redmine.",
)
ent_nmi.add_argument(
"--nmi-mode",
default=None,
help="NMI client mode (default: env NMI_MODE or dev).",
)
ent_nmi.add_argument(
"--nmi-env-file",
default=None,
help="Path to NMI .env (default: mrscClient examples/modern/.env).",
)
ent_nmi.add_argument(
"--nmi-bin",
default=None,
help="Path to bin/nmi (default: ~/projects/mrscClient-php/bin/nmi).",
)
ent_nmi.add_argument(
"--nmi-endpoint",
default=None,
help="Optional NMI endpoint override.",
)
ent_nmi.add_argument(
"--with-history",
action="store_true",
default=True,
help="Include item history from new getItemHistory endpoint (billings, moves, conditions) when item_no resolved. Default on.",
)
ent_nmi.add_argument(
"--no-history",
dest="with_history",
action="store_false",
help="Disable item history fetch for this run.",
)
ent_nmi.add_argument(
"--queue-private-note",
action="store_true",
help="Queue a private note draft with the brief (never emails). Use draft apply separately.",
)
ent_nmi.add_argument(
"--apply-private-note",
action="store_true",
help="Also apply the private note (requires normal write gates / --allow-prod-writes on prod).",
)
browse_parser = subparsers.add_parser(
"browse",
help="Interactive loop: fzf/list → pager (glow/bat) → action menu → back to list.",
)
browse_parser.add_argument("--where", action="append", default=[], help="Local property filter.")
browse_parser.add_argument("--control-tag", help="Local control-tag filter.")
browse_parser.add_argument("--status", help="Status name filter.")
browse_parser.add_argument("--max-tickets", type=int, default=80)
browse_parser.add_argument(
"--order",
choices=ORDER_CHOICES,
default="newest-updated-first",
)
browse_parser.add_argument(
"--pager",
default="auto",
help="Markdown pager: auto|glow|bat|batcat|cat (same as view).",
)
browse_parser.add_argument(
"--once",
action="store_true",
help="Pick one ticket, open pager, exit (no action menu / no loop).",
)
browse_parser.add_argument(
"--has-attachments",
action="store_true",
help="Only tickets with attachments metadata in cache.",
)
browse_parser.add_argument(
"--open-only",
action="store_true",
help="Exclude closed/resolved-ish statuses.",
)
browse_parser.add_argument(
"--no-preview",
action="store_true",
help="Disable fzf right-hand preview pane.",
)
if add_tui_parser is not None:
add_tui_parser(subparsers)
if add_textual_tui_parser is not None:
add_textual_tui_parser(subparsers)
ocr_parser = subparsers.add_parser(
"ocr-rename",
help="OCR/extract text from ticket attachments; propose subject rename + private note draft.",
)
ocr_parser.add_argument("--issue-id", type=int, help="Single issue id.")
ocr_parser.add_argument(
"--where",
action="append",
default=[],
help="Scan cached tickets matching --where (e.g. subject.includes(\"Scanned\")).",
)
ocr_parser.add_argument(
"--max-tickets",
type=int,
default=20,
help="Max tickets when scanning with --where (default 20).",
)
ocr_parser.add_argument(
"--only-generic-subjects",
action="store_true",
help="When scanning, skip tickets whose subject no longer looks like a generic scan title.",
)
ocr_parser.add_argument(
"--queue-subject",
action="store_true",
default=True,
help="Queue set-fields draft with proposed subject (default on).",
)
ocr_parser.add_argument(
"--no-queue-subject",
action="store_false",
dest="queue_subject",
help="Do not queue subject change draft.",
)
ocr_parser.add_argument(
"--queue-note",
action="store_true",
default=True,
help="Queue private internal note with OCR text snippet (default on).",
)
ocr_parser.add_argument(
"--no-queue-note",
action="store_false",
dest="queue_note",
help="Do not queue OCR note draft.",
)
ocr_parser.add_argument(
"--apply-subject",
action="store_true",
help="Apply subject change now (LAN/prod safety gates apply). Still queues note unless --no-queue-note.",
)
ocr_parser.add_argument(
"--impersonate-user",
default="rebot",
help="Used only with --apply-subject (default: rebot).",
)
ocr_parser.add_argument(
"--json",
action="store_true",
help="Print machine-readable results.",
)
cat_parser = subparsers.add_parser(
"categories",
help="List (and later manage) project issue categories for triage.",
)
cat_sub = cat_parser.add_subparsers(dest="categories_command")
cat_list = cat_sub.add_parser("list", help="List issue categories for the project.")
cat_list.add_argument(
"--refresh",
action="store_true",
help="Refresh from Redmine API (also updates meta cache).",
)
cat_list.add_argument("--json", action="store_true")
ctag_parser = subparsers.add_parser(
"control-tag",
help="LOCAL-only control tags (not Redmine tags). Label tickets for filters/automation.",
)
ctag_sub = ctag_parser.add_subparsers(dest="control_tag_command")
ctag_add = ctag_sub.add_parser("add", help="Tag a ticket locally.")
ctag_add.add_argument("--tag", required=True, help="Local tag name (no spaces; e.g. bavaria).")
ctag_add.add_argument("--issue-id", type=int, required=True)
ctag_add.add_argument(
"--property",
action="append",
default=[],
dest="properties",
help="JSON path that justified the tag (repeatable).",
)
ctag_add.add_argument("--annotation", default="", help="Optional free-text reason.")
ctag_rm = ctag_sub.add_parser("remove", help="Remove a local tag from a ticket.")
ctag_rm.add_argument("--tag", required=True)
ctag_rm.add_argument("--issue-id", type=int, required=True)
ctag_list = ctag_sub.add_parser("list", help="List local control tags and ticket counts.")
ctag_show = ctag_sub.add_parser("show", help="Show tickets for a local tag.")
ctag_show.add_argument("--tag", required=True)
ctag_for = ctag_sub.add_parser("for-issue", help="List local control tags on one ticket.")
ctag_for.add_argument("--issue-id", type=int, required=True)
ctag_suggest = ctag_sub.add_parser(
"suggest",
help="Rule-based suggestions for a control tag over cached tickets (does not write unless --apply).",
)
ctag_suggest.add_argument("--tag", required=True)
ctag_suggest.add_argument(
"--where",
action="append",
default=[],
help="Property query candidates (repeatable AND). Example: subject.includes(\"bavaria\")",
)
ctag_suggest.add_argument(
"--max-tickets",
type=int,
default=50,
help="Max candidates to propose.",
)
ctag_suggest.add_argument(
"--apply",
action="store_true",
help="Write suggested tags locally (still not Redmine).",
)
ctag_suggest.add_argument(
"--property",
action="append",
default=["subject"],
dest="properties",
help="Properties to record as rationale when applying (default: subject).",
)
ctag_export = ctag_sub.add_parser(
"export",
help="Export tagged tickets as JSONL for training / LLM refine (local only).",
)
ctag_export.add_argument("--tag", help="Only this control tag (default: all tags).")
ctag_export.add_argument(
"--out",
type=Path,
required=True,
help="Output .jsonl path.",
)
ctag_wipe = ctag_sub.add_parser(
"wipe",
help="Delete the entire local control-tag store (does not touch Redmine or ticket cache).",
)
ctag_wipe.add_argument(
"--i-understand-this-wipes-local-control-tags",
action="store_true",
required=True,
help="Mandatory confirmation.",
)
spam_parser = subparsers.add_parser("spam-check", help="Score cached issues for obvious spam/promo signals.")
spam_parser.add_argument("--apply", action="store_true", help="Record spam-check result in manifest for idempotency.")
spam_parser.add_argument("--threshold", type=int, default=2, help="Minimum score to report SPAM.")
close_parser = subparsers.add_parser("close-if-spam", help="Close cached tickets previously marked spam and review sender history.")
close_parser.add_argument("--apply", action="store_true", help="Actually update Redmine issues and record close/contact-review markers.")
close_parser.add_argument("--close-status-id", type=int, default=5, help="Redmine status_id to set when closing spam tickets.")
close_parser.add_argument("--contact-ticket-limit", type=int, default=10, help="Recent sender/contact tickets to review before contact-level spam judgment.")
close_parser.add_argument("--threshold", type=int, default=2, help="Minimum ticket spam score used during contact lookback.")
close_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for write calls; sent as X-Redmine-Switch-User only when --apply updates issues.")
maybe_parser = subparsers.add_parser("tag-maybe-customer", help="Tag contacts needing manual customer review based on inbound/business inquiry evidence.")
maybe_parser.add_argument("--apply", action="store_true", help="Actually add the tag to matching Redmine contacts.")
maybe_parser.add_argument("--tag", default="maybe-customer", help="Contact tag to add for manual review.")
maybe_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for contact tag writes; sent only with --apply.")
decompose_parser = subparsers.add_parser("decompose", help="Build a dry-run ticket decomposition plan without writing vault or Redmine state.")
decompose_parser.add_argument("--issue-id", type=int, help="Redmine issue id to decompose. Uses cache first, then live API if needed.")
decompose_parser.add_argument("--input-json", type=Path, help="Read a cached/enriched issue JSON file instead of fetching by id.")
decompose_parser.add_argument("--format", choices=("json", "markdown"), default="json", help="Output format for the dry-run plan.")
kscan_parser = subparsers.add_parser("known-spam-scan", help="Scan for tickets matching known spam profiles (dry-run).")
kscan_parser.add_argument("--profiles", type=Path, default=Path("profiles/known-spam/customer-service.json"), help="Path to known-spam profiles JSON.")
kscan_parser.add_argument("--max-tickets", type=int, help="Limit number of tickets to scan.")
kclose_parser = subparsers.add_parser("known-spam-close", help="Close tickets matching known spam profiles.")
kclose_parser.add_argument("--profiles", type=Path, default=Path("profiles/known-spam/customer-service.json"), help="Path to known-spam profiles JSON.")
kclose_parser.add_argument("--apply", action="store_true", help="Actually close matching Redmine tickets.")
kclose_parser.add_argument("--close-status-id", type=int, default=5, help="Redmine status_id to use when closing.")
kclose_parser.add_argument("--max-tickets", type=int, help="Limit number of tickets to process.")
kclose_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for write calls.")
return parser
def add_common_filters(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--updated-since",
help="LOCAL post-filter only: keep issues updated on/after date or datetime (applied after API list).",
)
parser.add_argument(
"--updated-before",
help="LOCAL post-filter only: keep issues updated on/before date or datetime (applied after API list).",
)
parser.add_argument(
"--status",
help="LOCAL post-filter only: filter by status name after fetch/cache load (e.g. New, Closed).",
)
parser.add_argument(
"--category",
help="LOCAL post-filter only: filter by category name after fetch/cache load.",
)
parser.add_argument("--max-tickets", type=int, help="Maximum ticket count after filters/order.")
parser.add_argument("--order", choices=ORDER_CHOICES, default="newest-updated-first")
def add_remote_sync_filters(parser: argparse.ArgumentParser) -> None:
"""Server-side Redmine REST filters used by the sync list call."""
parser.add_argument(
"--status-id",
"--remote-status-id",
dest="status_id",
default="*",
help=(
"SERVER-SIDE Redmine API filter `status_id`. "
"Values: * (open+closed), open, closed, or numeric status id. Default: *."
),
)
parser.add_argument(
"--remote-updated-on",
help=(
"SERVER-SIDE raw Redmine API filter for `updated_on` (official operators). "
"Examples: '>=2026-06-14', '>=2014-01-02T08:12:32Z', "
"'><2026-01-01|2026-06-01'. Mutually exclusive with the convenience flags."
),
)
parser.add_argument(
"--remote-updated-since",
help=(
"SERVER-SIDE convenience for updated_on >= .... Accepts YYYY-MM-DD, ISO datetime, "
"or relative forms: 30d, 2h, 15m, 1w, '2 days ago'. Builds official Redmine operator syntax."
),
)
parser.add_argument(
"--remote-updated-before",
help=(
"SERVER-SIDE convenience for updated_on <= .... Same value forms as --remote-updated-since. "
"Combined with --remote-updated-since as Redmine range operator ><since|before."
),
)
KNOWN_COMMANDS = frozenset(
{
"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",
"known-spam-scan",
"known-spam-close",
}
)
# 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"})
def inject_default_sync_command(argv: list[str]) -> list[str]:
"""Insert `sync` when the user omitted a subcommand.
Argparse subparsers only accept their own flags *after* the command name.
Without this, a natural invocation like:
./redmine_tickets.py --project customer-service --updated-since 2026-07-12
fails with: invalid choice: '2026-07-12' (argparse treats the date as the command).
"""
if any(arg in KNOWN_COMMANDS for arg in argv):
return argv
i = 0
while i < len(argv):
arg = argv[i]
if arg in KNOWN_COMMANDS:
return argv
if arg in _PARENT_VALUE_OPTIONS:
i += 2
continue
if arg.startswith("--base-url=") or arg.startswith("--project=") or arg.startswith("--cache-dir="):
i += 1
continue
if arg in {"-h", "--help"}:
# Leave bare --help alone so top-level help still works.
return argv
# First unknown option/positional belongs to the default sync command.
return argv[:i] + ["sync"] + argv[i:]
# No args after parent globals (or empty): default to sync.
return argv + ["sync"]
def main(argv: list[str] | None = None) -> int:
raw_argv = list(argv) if argv is not None else sys.argv[1:]
try:
raw_argv = apply_runtime_config_to_argv(raw_argv)
except RedmineError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
normalized_argv = inject_default_sync_command(raw_argv)
parser = build_parser()
args = parser.parse_args(normalized_argv)
# Post-process common globals so subparsers (tui/browse/...) that re-declare them
# (to support "cmd --allow-prod-writes ...") don't leave them as None.
if getattr(args, "cache_dir", None) in (None, ""):
from pathlib import Path as _P
args.cache_dir = _P(".cache/redmine_tickets")
if getattr(args, "project", None) in (None, ""):
args.project = "customer-service"
if getattr(args, "base_url", None) in (None, ""):
args.base_url = os.getenv("REDMINE_BASE_URL", "https://rm.ldrprep.com")
# Also normalize the write flag so tui/browse can rely on it
if not getattr(args, "allow_prod_writes", False):
env_flag = os.getenv("REDMINE_ALLOW_PROD_WRITES", "").lower() in ("1", "true", "yes")
setattr(args, "allow_prod_writes", env_flag)
cache = TicketCache(args.cache_dir, args.project)
# Keep the interactive TUI dispatch independent of the legacy command chain
# below. That chain historically became nested under the sync branch, which
# made `rtl tui` parse normally and then silently fall through to return 1.
if args.command == "tui":
if run_tui_cli is None:
raise RedmineError("TUI unavailable (redmine_ticket_tui.py failed to load).")
return run_tui_cli(args, cache)
if args.command == "sync":
run_log_path = resolve_run_log_path(getattr(args, "run_log", None), args.cache_dir)
started_at = now_iso()
started_mono = time.monotonic()
remote_updated_on = None
sync_result: SyncResult | None = None
try:
client = client_from_args(args)
remote_updated_on = build_remote_updated_on_value(
raw=getattr(args, "remote_updated_on", None),
since=getattr(args, "remote_updated_since", None),
before=getattr(args, "remote_updated_before", None),
)
sync_result = sync_tickets(
client,
cache,
updated_since=args.updated_since,
updated_before=args.updated_before,
status=args.status,
category=args.category,
max_tickets=args.max_tickets,
order=args.order,
apply=not args.dry_run,
include_journals=not args.no_journals,
page_size=args.page_size,
status_id=args.status_id,
remote_updated_on=remote_updated_on,
progress=not args.quiet,
)
print(
f"Sync complete: seen={sync_result.seen} fetched={sync_result.fetched} "
f"skipped_cached={sync_result.skipped_cached} written={sync_result.written}"
)
if args.dry_run:
print("Dry run only. Cache was not written.")
if run_log_path is not None:
entry = build_sync_run_entry(
started_at=started_at,
finished_at=now_iso(),
duration_seconds=time.monotonic() - started_mono,
args=args,
remote_updated_on=remote_updated_on,
result=sync_result,
ok=True,
)
written_path = append_run_stats(
run_log_path,
entry,
keep=max(1, int(getattr(args, "run_log_keep", DEFAULT_RUN_LOG_KEEP) or DEFAULT_RUN_LOG_KEEP)),
)
if not args.quiet:
print(f"Run log updated: {written_path} (kept last {args.run_log_keep})")
return 0
except RedmineError as exc:
if run_log_path is not None:
entry = build_sync_run_entry(
started_at=started_at,
finished_at=now_iso(),
duration_seconds=time.monotonic() - started_mono,
args=args,
remote_updated_on=remote_updated_on,
result=sync_result,
ok=False,
error=str(exc),
)
try:
append_run_stats(
run_log_path,
entry,
keep=max(1, int(getattr(args, "run_log_keep", DEFAULT_RUN_LOG_KEEP) or DEFAULT_RUN_LOG_KEEP)),
)
except OSError:
pass
raise
if args.command == "search":
matches = select_cached_issues_for_cli(cache, args)
if not matches:
print("No matching tickets.", file=sys.stderr)
return 0
ids_only = getattr(args, "ids_only", False)
do_json = getattr(args, "json", False)
do_jsonl = getattr(args, "jsonl", False)
if ids_only:
for issue in matches:
print(issue.get("id"))
elif do_json:
print(json.dumps(matches, indent=2, sort_keys=True, default=str))
elif do_jsonl:
for issue in matches:
print(json.dumps(issue, default=str))
else:
for issue in matches:
iid = issue.get("id")
subj = str(issue.get("subject") or "").replace("\n", " ")[:70]
st = (issue.get("status") or {}).get("name", "?") if isinstance(issue.get("status"), dict) else "?"
who = (issue.get("assigned_to") or {}).get("name", "unassigned") if isinstance(issue.get("assigned_to"), dict) else "unassigned"
print(f"#{iid}\t{st}\t{who}\t{subj}")
return 0
# Command dispatch below MUST stay at this indent (sibling of search/sync/tui).
# Nesting these under search after `return 0` made ocr-rename/view/attachments
# silently fall through to exit 1 with no output (2026-09-09).
if args.command == "view":
if render_ticket_markdown is None or write_ticket_markdown is None or display_markdown_file is None:
raise RedmineError("Ticket view helpers unavailable (redmine_ticket_view.py).")
issue_id = getattr(args, "issue_id", None) or getattr(args, "issue_id_pos", None)
if issue_id is None:
raise RedmineError("view requires --issue-id or a positional issue id.")
try:
issue = load_cached_issue(cache, int(issue_id))
except RedmineError:
client = client_from_args(args)
issue = client.get_issue_with_includes(
int(issue_id), ("attachments", "journals", "helpdesk")
)
try:
cache.write_issue(issue)
except Exception:
pass
md = render_ticket_markdown(issue)
if getattr(args, "write_md", False) or getattr(args, "force_md", False) or not getattr(args, "print_only", False):
md_path = write_ticket_markdown(
issue,
Path(args.cache_dir),
args.project,
force=bool(getattr(args, "force_md", False)),
)
else:
md_path = None
if getattr(args, "print_only", False):
print(md, end="" if md.endswith("\n") else "\n")
return 0
return display_markdown_file(md_path, pager=getattr(args, "pager", "auto") or "auto")
if args.command == "walk":
if args.ids:
ids: list[int] = []
for part in str(args.ids).split(","):
part = part.strip()
if not part:
continue
ids.append(int(part))
issues = []
for issue_id in ids:
try:
issues.append(load_cached_issue(cache, issue_id))
except RedmineError as exc:
print(f"warning: {exc}", file=sys.stderr)
else:
issues = select_cached_issues_for_cli(cache, args)
return run_walk_session(issues, cache, pager=args.pager)
if args.command == "materialize-queues":
if load_queue_definitions is None or materialize_queue is None or filter_by_query is None:
raise RedmineError("Queue helpers unavailable (redmine_ticket_filters.py).")
config_path = args.config
if not config_path.is_file():
# try next to script
alt = Path(__file__).resolve().parent / config_path
if alt.is_file():
config_path = alt
if not config_path.is_file():
raise RedmineError(f"Queue config not found: {args.config}")
queues = load_queue_definitions(config_path)
queues_root = args.queues_dir or (Path(args.cache_dir) / "queues")
saved_dir = Path(args.saved_filters_dir or DEFAULT_SAVED_FILTERS_DIR)
script_saved = Path(__file__).resolve().parent / "saved-filters"
search_dirs = [saved_dir]
if script_saved.is_dir() and script_saved not in search_dirs:
search_dirs.append(script_saved)
all_issues = list(cache.iter_cached_issues())
for qdef in queues:
name = str(qdef["name"])
where_raw = list(qdef.get("where") or [])
if isinstance(qdef.get("filter"), str):
where_raw.append(str(qdef["filter"]))
try:
if expand_where_list is not None:
where_exprs = expand_where_list(where_raw, search_dirs)
else:
where_exprs = where_raw
matched = filter_by_query(all_issues, expressions=where_exprs) if where_exprs else list(all_issues)
except QueryError as exc:
raise RedmineError(f"Queue {name!r} filter error: {exc}") from exc
matched = select_issues(
matched,
order=str(qdef.get("order") or "newest-updated-first"),
max_tickets=qdef.get("max_tickets"),
)
if args.dry_run:
print(f"queue={name} would_link={len(matched)} ids={[i.get('id') for i in matched[:20]]}")
continue
manifest = materialize_queue(
name=name,
issues=matched,
cache_dir=Path(args.cache_dir),
project=args.project,
queues_root=queues_root,
)
print(
f"queue={name} linked={manifest['linked']} "
f"missing_source={manifest['missing_source']} dir={queues_root / name}"
)
return 0
if args.command == "note":
body = resolve_body_text(args)
print("ACTION: INTERNAL NOTE")
print(" emails_customer: NO")
print(f" issue_id: {args.issue_id}")
print(f" private: {bool(args.private)}")
if not args.apply:
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
path = add_draft(
Path(args.cache_dir),
issue_id=int(args.issue_id),
kind=KIND_INTERNAL_NOTE,
body=body,
private=bool(args.private),
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_INTERNAL_NOTE,
issue_id=int(args.issue_id),
base_url=args.base_url,
emails_customer=False,
)
return 0
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args))
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
client.add_internal_note(
int(args.issue_id),
body,
private=bool(args.private),
impersonate_user=args.impersonate_user,
)
# Sync local cache so the new journal/note is visible in TUI/browse without full re-sync
try:
if refresh_cached_issue:
c = TicketCache(Path(args.cache_dir), args.project)
refresh_cached_issue(c, client, int(args.issue_id))
except Exception:
pass
print(f"applied internal note on #{args.issue_id} (no customer email)")
return 0
if args.command == "set-fields":
resolved = resolve_set_fields_names(args)
fields = build_fields_payload_from_args(args, resolved_ids=resolved)
print("ACTION: FIELD UPDATE")
print(" emails_customer: NO")
print(f" issue_id: {args.issue_id}")
if resolved:
print(f" resolved_names: {json.dumps(resolved, sort_keys=True)}")
print(f" fields: {json.dumps(fields, sort_keys=True)}")
if not args.apply:
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
path = add_draft(
Path(args.cache_dir),
issue_id=int(args.issue_id),
kind=KIND_FIELDS,
body="",
fields=fields,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_FIELDS,
issue_id=int(args.issue_id),
base_url=args.base_url,
emails_customer=False,
)
return 0
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args))
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
client.update_issue(int(args.issue_id), fields, impersonate_user=args.impersonate_user)
# Sync local cache with live so TUI/browse/etc. see the updated status/assignee/etc. immediately
try:
if refresh_cached_issue:
c = TicketCache(Path(args.cache_dir), args.project)
refresh_cached_issue(c, client, int(args.issue_id))
except Exception:
pass
print(f"applied field update on #{args.issue_id} (no customer email)")
return 0
if args.command == "meta-refresh":
if refresh_meta_cache is None:
raise RedmineError("Meta helpers unavailable (redmine_ticket_meta.py).")
client = client_from_args(args)
paths = refresh_meta_cache(
client,
Path(args.cache_dir),
args.project,
force=bool(args.force),
)
for name, path in paths.items():
print(f"{name}\t{path}")
return 0
if args.command == "contacts-sync":
if ContactCache is None or sync_contacts is None:
raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).")
client = client_from_args(args)
ccache = ContactCache(Path(args.cache_dir))
result = sync_contacts(
client,
ccache,
page_size=int(args.page_size),
max_contacts=args.max_contacts,
progress=not bool(args.quiet),
dry_run=bool(args.dry_run),
)
print(json.dumps(result, sort_keys=True))
return 0
if args.command == "contacts-search":
if ContactCache is None or filter_contacts is None:
raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).")
ccache = ContactCache(Path(args.cache_dir))
contacts = list(ccache.iter_contacts())
matches = filter_contacts(
contacts,
query=args.query,
email=args.email,
company=args.company,
tag=args.tag,
)
if args.max_contacts is not None:
matches = matches[: int(args.max_contacts)]
if args.json:
print(json.dumps(matches, indent=2, sort_keys=True))
return 0
if args.ids_only:
for c in matches:
if c.get("id") is not None:
print(c["id"])
return 0
print(f"matches={len(matches)}", file=sys.stderr)
for c in matches:
name = contact_display_name(c) if contact_display_name else c.get("name")
email = primary_email(c) if primary_email else c.get("email")
print(f"#{c.get('id')}\t{name}\t{email}\t{c.get('company') or ''}")
return 0
if args.command == "contact-show":
if ContactCache is None:
raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).")
ccache = ContactCache(Path(args.cache_dir))
cid = int(args.contact_id)
try:
contact = ccache.load_contact(cid)
except FileNotFoundError:
if not args.fetch:
raise RedmineError(
f"No cached contact #{cid}. Run contacts-sync or contact-show --fetch."
)
client = client_from_args(args)
contact = client.get_contact(cid)
ccache.write_contact(contact)
if args.json:
print(json.dumps(contact, indent=2, sort_keys=True))
else:
name = contact_display_name(contact) if contact_display_name else contact.get("name")
email = primary_email(contact) if primary_email else contact.get("email")
print(f"id={contact.get('id')}")
print(f"name={name}")
print(f"email={email}")
print(f"company={contact.get('company') or ''}")
print(f"updated_on={contact.get('updated_on') or ''}")
return 0
if args.command == "ticket-contact":
if contact_id_from_issue is None:
raise RedmineError("Contact bridge helper unavailable.")
issue = load_cached_issue(cache, int(args.issue_id))
cid = contact_id_from_issue(issue)
if cid is None:
raise RedmineError(
f"Ticket #{args.issue_id} has no helpdesk contact id in cache. "
"Sync with journals,helpdesk or open a non-helpdesk issue?"
)
if args.json:
print(
json.dumps(
{
"issue_id": int(args.issue_id),
"contact_id": cid,
"from_address": (
(issue.get("helpdesk_ticket") or {}).get("from_address")
if isinstance(issue.get("helpdesk_ticket"), dict)
else None
),
},
indent=2,
sort_keys=True,
)
)
else:
print(f"issue_id={args.issue_id}")
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}"
)
# --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))
if want_show:
if ContactCache is None:
raise RedmineError("Contact helpers unavailable.")
ccache = ContactCache(Path(args.cache_dir))
contact: dict[str, Any]
if want_fetch:
client = client_from_args(args)
contact = client.get_contact(cid)
ccache.write_contact(contact)
print(f"fetched_contact=yes contact_id={cid}")
else:
try:
contact = ccache.load_contact(cid)
except FileNotFoundError as exc:
raise RedmineError(
f"Contact #{cid} not in local contacts cache. "
"Re-run with --fetch (or contacts-sync / contact-show --fetch)."
) from exc
name = contact_display_name(contact) if contact_display_name else contact.get("name")
email = primary_email(contact) if primary_email else contact.get("email")
print(f"name={name}")
print(f"email={email}")
print(f"company={contact.get('company') or ''}")
return 0
if args.command == "contact-set":
if ContactCache is None:
raise RedmineError("Contact helpers unavailable.")
cid = int(args.contact_id)
# Prefer cache, fall back to API for tag merge base
contact: dict[str, Any] | None = None
ccache = ContactCache(Path(args.cache_dir))
try:
contact = ccache.load_contact(cid)
except FileNotFoundError:
contact = None
if contact is None and (args.add_tag or args.remove_tag or args.apply):
client = client_from_args(args)
contact = client.get_contact(cid)
ccache.write_contact(contact)
fields: dict[str, Any] = {}
for assignment in list(args.contact_sets or []):
if "=" not in assignment:
raise RedmineError(f"Invalid --set value: {assignment!r} (want KEY=VALUE)")
key, value = assignment.split("=", 1)
key = key.strip()
if not key:
raise RedmineError(f"Invalid --set value: {assignment!r}")
fields[key] = value
if args.company is not None:
fields["company"] = args.company
if args.first_name is not None:
fields["first_name"] = args.first_name
if args.last_name is not None:
fields["last_name"] = args.last_name
if args.add_tag or args.remove_tag:
if contact is None:
raise RedmineError(
"Tag changes need a contact base payload. Fetch failed or contact missing."
)
fields["tag_list"] = merge_tags(
extract_tag_list(contact),
list(args.add_tag or []),
list(args.remove_tag or []),
)
if not fields:
raise RedmineError(
"contact-set requires at least one of: --add-tag, --remove-tag, "
"--company, --first-name, --last-name, --set KEY=VALUE"
)
print("ACTION: CONTACT UPDATE")
print(" emails_customer: NO")
print(f" contact_id: {cid}")
print(f" fields: {json.dumps(fields, sort_keys=True)}")
if not args.apply:
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
path = add_draft(
Path(args.cache_dir),
contact_id=cid,
kind=KIND_CONTACT_FIELDS,
fields=fields,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_CONTACT_FIELDS,
contact_id=cid,
base_url=args.base_url,
emails_customer=False,
)
return 0
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args))
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
client.update_contact(cid, fields, impersonate_user=args.impersonate_user)
print(f"applied contact update on contact #{cid} (no customer email)")
if args.refresh_cache:
fresh = client.get_contact(cid)
ccache.write_contact(fresh)
print(f"refreshed local contact cache for #{cid}")
return 0
if args.command == "attachments":
if attachments_from_issue is None or pick_attachment is None:
raise RedmineError("Attachment helpers unavailable (redmine_ticket_attachments.py).")
cmd = args.attachments_command
if cmd is None:
raise RedmineError("attachments requires subcommand: list | download | upload")
def load_issue_atts(issue_id: int, refresh: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
issue: dict[str, Any] | None = None
if not refresh:
try:
issue = load_cached_issue(cache, issue_id)
except RedmineError:
issue = None
if refresh or issue is None or not attachments_from_issue(issue or {}):
client = client_from_args(args)
issue = client.get_issue_with_includes(
issue_id, ("attachments", "journals", "helpdesk")
)
# best-effort write-through to ticket cache
try:
cache.write_issue(issue)
except Exception:
pass
assert issue is not None
return issue, attachments_from_issue(issue)
if cmd == "list":
issue, atts = load_issue_atts(int(args.issue_id), bool(args.refresh))
if args.json:
print(json.dumps(atts, indent=2, sort_keys=True))
return 0
print(f"issue_id={args.issue_id} attachments={len(atts)}", file=sys.stderr)
for att in atts:
print(
f"#{att.get('id')}\t{att.get('filesize') or att.get('file_size') or ''}\t"
f"{att.get('content_type') or ''}\t{att.get('filename') or att.get('filename')}"
)
if not atts:
print(
"No attachments in payload. Try: attachments list --issue-id N --refresh",
file=sys.stderr,
)
return 0
if cmd == "download":
if download_attachment_bytes is None or download_url_for_attachment is None:
raise RedmineError("Download helpers unavailable.")
if safe_filename is None or attachment_dir is None:
raise RedmineError("Path helpers unavailable.")
issue_id = int(args.issue_id)
_issue, atts = load_issue_atts(issue_id, bool(args.refresh))
if args.all:
targets = atts
else:
try:
targets = [pick_attachment(atts, args.attachment_id)]
except LookupError as exc:
raise RedmineError(str(exc)) from exc
out_dir = args.out_dir or attachment_dir(Path(args.cache_dir), args.project, issue_id)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
api_key = os.getenv("REDMINE_API_KEY")
if not api_key:
raise RedmineError("REDMINE_API_KEY required to download attachments.")
for att in targets:
url = download_url_for_attachment(att, args.base_url)
name = safe_filename(str(att.get("filename") or f"attachment-{att.get('id')}"))
dest = out_dir / name
# avoid clobber if same name different id
if dest.exists() and att.get("id") is not None:
dest = out_dir / f"{att.get('id')}_{name}"
data = download_attachment_bytes(url=url, api_key=api_key)
dest.write_bytes(data)
print(f"downloaded attachment_id={att.get('id')} path={dest} bytes={len(data)}")
return 0
if cmd == "upload":
if upload_file_token is None:
raise RedmineError("Upload helpers unavailable.")
path = Path(args.file)
if not path.is_file():
raise RedmineError(f"File not found: {path}")
print("ACTION: ATTACHMENT UPLOAD")
print(" emails_customer: NO")
print(f" issue_id: {args.issue_id}")
print(f" file: {path}")
print(f" size_bytes: {path.stat().st_size}")
if not args.apply:
print("dry_run=yes (pass --apply to upload+attach)")
print(
f"apply_hint=attachments upload --issue-id {args.issue_id} "
f"--file {path} --apply"
)
return 0
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(
args.base_url, allow_prod_writes=allow_prod_writes_enabled(args)
)
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
api_key = os.getenv("REDMINE_API_KEY")
if not api_key:
raise RedmineError("REDMINE_API_KEY required for upload.")
upload = upload_file_token(
base_url=args.base_url,
api_key=api_key,
path=path,
filename=args.filename,
impersonate_user=args.impersonate_user,
)
if args.description:
upload["description"] = args.description
client = client_from_args(args)
client.attach_upload_to_issue(
int(args.issue_id),
upload,
notes=args.notes,
impersonate_user=args.impersonate_user,
)
print(
f"attached token={upload.get('token')} filename={upload.get('filename')} "
f"to issue #{args.issue_id}"
)
return 0
raise RedmineError(f"Unknown attachments subcommand: {cmd}")
if args.command == "entities":
if extract_entities_from_issue is None:
raise RedmineError("Entity helpers unavailable (redmine_ticket_entities.py).")
ecmd = args.entities_command
if ecmd is None:
raise RedmineError("entities requires subcommand: show | scan | nmi")
if ecmd == "show":
issue = load_cached_issue(cache, int(args.issue_id))
ents = extract_entities_from_issue(issue)
if args.json:
print(json.dumps(ents, indent=2, sort_keys=True))
else:
print(f"issue_id={args.issue_id} entities={len(ents)}")
for e in ents:
print(f"{e.get('type')}\t{e.get('value')}\t{e.get('confidence')}")
if not ents:
print("(no pattern hits)", file=sys.stderr)
return 0
if ecmd == "scan":
issues = select_cached_issues_for_cli(cache, args)
# select uses where/max from args - ensure max_tickets/order present
hits_out = []
for issue in issues:
ents = extract_entities_from_issue(issue)
if getattr(args, "entity_type", None):
ents = [e for e in ents if e.get("type") == args.entity_type]
if not ents:
continue
iid = issue.get("id")
if args.ids_only:
print(iid)
elif args.jsonl:
for e in ents:
print(
json.dumps(
{
"issue_id": iid,
"subject": issue.get("subject"),
**e,
},
sort_keys=True,
)
)
else:
line = format_entities_line(ents) if format_entities_line else str(ents)
subj = str(issue.get("subject") or "").replace("\n", " ")[:70]
print(f"#{iid}\t{line}\t{subj}")
hits_out.append(iid)
print(f"tickets_with_hits={len(hits_out)}", file=sys.stderr)
return 0
if ecmd == "nmi":
if brief_for_issue is None or format_private_note is None:
raise RedmineError("NMI bridge unavailable (redmine_ticket_nmi.py).")
issue = load_cached_issue(cache, int(args.issue_id))
ents = extract_entities_from_issue(issue)
kwargs = {}
if getattr(args, "nmi_mode", None):
kwargs["mode"] = args.nmi_mode
if getattr(args, "nmi_env_file", None):
kwargs["env_file"] = args.nmi_env_file
if getattr(args, "nmi_bin", None):
kwargs["nmi_bin"] = args.nmi_bin
if getattr(args, "nmi_endpoint", None):
kwargs["endpoint"] = args.nmi_endpoint
# History is fetched inside when item_no present; respect explicit --no-history
if not getattr(args, "with_history", True):
# caller can still pass to skip in future
pass
payload = brief_for_issue(issue, ents, **kwargs)
fmt = getattr(args, "format", "brief") or "brief"
note_body = format_private_note(payload)
if getattr(args, "queue_private_note", False) or getattr(args, "apply_private_note", False):
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
draft_path = add_draft(
Path(args.cache_dir),
issue_id=int(args.issue_id),
kind=KIND_INTERNAL_NOTE,
body=note_body,
private=True,
base_url=getattr(args, "base_url", None),
project=getattr(args, "project", None),
)
draft_id = draft_path.stem if hasattr(draft_path, "stem") else Path(str(draft_path)).stem
if print_draft_queued is not None:
print_draft_queued(
draft_path,
kind=KIND_INTERNAL_NOTE,
issue_id=int(args.issue_id),
base_url=getattr(args, "base_url", None),
emails_customer=False,
)
else:
print(f"draft_id={draft_id}")
print(f"draft_path={draft_path}")
print(f"issue_id={args.issue_id}")
print(f"kind={KIND_INTERNAL_NOTE}")
print("emails_customer=NO")
print(f"apply_hint=draft apply {draft_id}")
if getattr(args, "apply_private_note", False):
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(
args.base_url,
allow_prod_writes=allow_prod_writes_enabled(args),
)
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
_, draft_data = load_draft(Path(args.cache_dir), draft_id)
apply_draft_to_redmine(
client,
draft_data,
impersonate_user=getattr(args, "impersonate_user", None) or "rebot",
allow_customer_email=False,
)
if mark_draft_applied is not None:
mark_draft_applied(Path(args.cache_dir), draft_id)
try:
c = TicketCache(Path(args.cache_dir), getattr(args, "project", "customer-service"))
refresh_cached_issue(c, client, int(args.issue_id))
except Exception:
pass
print(f"applied private NMI brief note on #{args.issue_id} (no customer email)", file=sys.stderr)
if fmt == "json":
print(json.dumps(payload, indent=2, sort_keys=True, default=str))
elif fmt == "note":
print(note_body, end="" if note_body.endswith("\n") else "\n")
else:
brief = (payload.get("brief") or {})
if brief.get("ok") and brief.get("text"):
print(brief["text"], end="" if str(brief["text"]).endswith("\n") else "\n")
else:
print(note_body, end="" if note_body.endswith("\n") else "\n")
return 1 if not payload.get("ok") else 0
return 0 if payload.get("ok") else 1
raise RedmineError(f"Unknown entities subcommand: {ecmd}")
if args.command == "tui":
if run_tui_cli is None:
raise RedmineError("TUI unavailable (redmine_ticket_tui.py). Run with python -c or check import.")
return run_tui_cli(args, cache)
if args.command == "textual-tui":
if run_textual_tui_cli is None:
raise RedmineError("Textual TUI unavailable (redmine_ticket_textual_tui.py).")
return run_textual_tui_cli(args, cache)
if args.command == "browse":
if run_browse_loop is None:
raise RedmineError("Browse loop helper unavailable (redmine_ticket_browse.py).")
matches = select_cached_issues_for_cli(cache, args)
if not matches:
print("No matching tickets.", file=sys.stderr)
return 0
def make_handlers(issue_id: int, issue: dict[str, Any]) -> dict[str, Callable[[], None]]:
def do_note(private: bool = False) -> None:
if add_draft is None or prompt_multiline is None:
raise RedmineError("Draft/note helpers unavailable.")
body = prompt_multiline(
"Internal note body" + (" (PRIVATE)" if private else "")
)
if not body:
print("cancelled", file=sys.stderr)
return
path = add_draft(
Path(args.cache_dir),
issue_id=int(issue_id),
kind=KIND_INTERNAL_NOTE,
body=body,
private=private,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_INTERNAL_NOTE,
issue_id=int(issue_id),
base_url=args.base_url,
emails_customer=False,
)
if announce_draft:
announce_draft(path)
else:
print("Queued only — apply later with: draft apply <id>", file=sys.stderr)
def do_fields() -> None:
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
print("Field update (name-based). Blank line cancels.")
status = input("status name (e.g. Closed) or empty: ").strip()
assignee = input("assignee name/login or 'unassigned' or empty: ").strip()
notes = input("optional internal notes: ").strip()
# Build a fake namespace for resolver
ns = argparse.Namespace(
field_status_id=None,
status_name=status or None,
assigned_to_id=None,
assignee_name=assignee or None,
priority_id=None,
priority_name=None,
category_id=None,
category_name=None,
due_date=None,
subject=None,
notes=notes or None,
private=False,
refresh_meta=False,
base_url=args.base_url,
project=args.project,
cache_dir=args.cache_dir,
api_key=os.getenv("REDMINE_API_KEY"),
)
if not any([status, assignee, notes]):
print("cancelled", file=sys.stderr)
return
resolved = resolve_set_fields_names(ns)
fields = build_fields_payload_from_args(ns, resolved_ids=resolved)
if not fields:
print("no fields to set", file=sys.stderr)
return
path = add_draft(
Path(args.cache_dir),
issue_id=int(issue_id),
kind=KIND_FIELDS,
fields=fields,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_FIELDS,
issue_id=int(issue_id),
base_url=args.base_url,
emails_customer=False,
)
if announce_draft:
announce_draft(path)
def do_tag() -> None:
if control_tag_add is None:
raise RedmineError("Control-tag helpers unavailable.")
tag = input("local control-tag name: ").strip()
if not tag:
print("cancelled", file=sys.stderr)
return
ann = input("annotation (optional): ").strip()
detail = control_tag_add(
Path(args.cache_dir),
tag=tag,
issue_id=int(issue_id),
properties=["browse"],
annotation=ann,
)
print(json.dumps(detail, indent=2, sort_keys=True))
def do_contact() -> None:
if contact_id_from_issue is None:
raise RedmineError("Contact bridge unavailable.")
cid = contact_id_from_issue(issue)
print(f"issue_id={issue_id}")
print(f"contact_id={cid}")
if cid is not None:
print(f"handoff=contact-show --contact-id {cid}")
print(f"handoff=contact-set --contact-id {cid} --add-tag maybe-customer")
def do_entities() -> None:
if extract_entities_from_issue is None:
raise RedmineError("Entity helpers unavailable.")
ents = extract_entities_from_issue(issue)
if not ents:
print("(no pattern hits)")
return
for e in ents:
print(f"{e.get('type')}\t{e.get('value')}\t{e.get('confidence')}")
def do_nmi() -> None:
if extract_entities_from_issue is None or brief_for_issue is None or format_private_note is None:
raise RedmineError("NMI helpers unavailable.")
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
print("Running NMI brief (may take a few seconds)…", file=sys.stderr)
ents = extract_entities_from_issue(issue)
payload = brief_for_issue(issue, ents)
note_body = format_private_note(payload)
print(note_body[:2000])
if not payload.get("ok"):
print(
f"NMI lookup incomplete ({payload.get('error')}); "
"still queueing private note with attempt log.",
file=sys.stderr,
)
ans = input("Queue as PRIVATE internal note draft? [Y/n] ").strip().lower()
if ans in {"n", "no"}:
print("cancelled", file=sys.stderr)
return
path = add_draft(
Path(args.cache_dir),
issue_id=int(issue_id),
kind=KIND_INTERNAL_NOTE,
body=note_body,
private=True,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_INTERNAL_NOTE,
issue_id=int(issue_id),
base_url=args.base_url,
emails_customer=False,
)
if announce_draft:
announce_draft(path)
def do_id() -> None:
print(issue_id)
return {
"n": lambda: do_note(False),
"p": lambda: do_note(True),
"f": do_fields,
"t": do_tag,
"c": do_contact,
"e": do_entities,
"m": do_nmi,
"i": do_id,
}
return int(
run_browse_loop(
matches,
cache_dir=Path(args.cache_dir),
project=args.project,
pager=str(getattr(args, "pager", "auto") or "auto"),
once=bool(getattr(args, "once", False)),
preview=not bool(getattr(args, "no_preview", False)),
action_factory=make_handlers,
)
)
if args.command == "categories":
cmd = args.categories_command
if cmd is None:
raise RedmineError("categories requires subcommand: list")
if cmd == "list":
cats: list[dict[str, Any]] = []
if args.refresh or load_meta_lists is None:
if fetch_project_categories is None:
raise RedmineError("Category helpers unavailable.")
client = client_from_args(args)
cats = fetch_project_categories(client, args.project)
# best-effort meta write-through
if refresh_meta_cache is not None:
try:
refresh_meta_cache(
client, Path(args.cache_dir), args.project, force=True
)
except Exception:
pass
else:
lists = load_meta_lists(Path(args.cache_dir), args.project)
cats = list(lists.get("categories") or [])
if not cats and fetch_project_categories is not None:
client = client_from_args(args)
cats = fetch_project_categories(client, args.project)
if args.json:
print(json.dumps(cats, indent=2, sort_keys=True))
return 0
print(f"project={args.project} categories={len(cats)}", file=sys.stderr)
for c in cats:
print(f"#{c.get('id')}\t{c.get('name')}")
return 0
raise RedmineError(f"Unknown categories subcommand: {cmd}")
if args.command == "ocr-rename":
if (
extract_text_from_file is None
or suggest_subject_from_text is None
or attachment_is_ocr_candidate is None
):
raise RedmineError("OCR helpers unavailable (redmine_ticket_ocr.py).")
if download_attachment_bytes is None or download_url_for_attachment is None:
raise RedmineError("Attachment download helpers unavailable.")
if attachment_dir is None or safe_filename is None:
raise RedmineError("Attachment path helpers unavailable.")
targets: list[dict[str, Any]] = []
if args.issue_id is not None:
try:
targets = [load_cached_issue(cache, int(args.issue_id))]
except RedmineError:
client = client_from_args(args)
targets = [
client.get_issue_with_includes(
int(args.issue_id), ("attachments", "journals", "helpdesk")
)
]
elif args.where:
targets = select_cached_issues_for_cli(cache, args)
else:
raise RedmineError("ocr-rename requires --issue-id or --where.")
api_key = os.getenv("REDMINE_API_KEY")
if not api_key:
raise RedmineError("REDMINE_API_KEY required to download attachments for OCR.")
results: list[dict[str, Any]] = []
for issue in targets:
iid = int(issue.get("id") or 0)
if not iid:
continue
subj = str(issue.get("subject") or "")
if args.only_generic_subjects and looks_like_generic_scan_subject and not looks_like_generic_scan_subject(subj):
results.append({"issue_id": iid, "skipped": "subject_not_generic", "subject": subj})
continue
# ensure attachments present
atts = issue.get("attachments") if isinstance(issue.get("attachments"), list) else []
if not atts:
client = client_from_args(args)
issue = client.get_issue_with_includes(iid, ("attachments", "journals", "helpdesk"))
try:
cache.write_issue(issue)
except Exception:
pass
atts = issue.get("attachments") if isinstance(issue.get("attachments"), list) else []
cand_atts = [a for a in atts if isinstance(a, dict) and attachment_is_ocr_candidate(a)]
if not cand_atts:
results.append({"issue_id": iid, "skipped": "no_ocr_attachments", "subject": subj})
continue
out_dir = attachment_dir(Path(args.cache_dir), args.project, iid)
file_results: list[dict[str, str]] = []
combined_text_parts: list[str] = []
for att in cand_atts[:5]:
name = safe_filename(str(att.get("filename") or f"att-{att.get('id')}"))
dest = out_dir / name
try:
if not dest.is_file():
from redmine_ticket_attachments import download_url_candidates
urls = download_url_candidates(att, args.base_url)
data = download_attachment_bytes(
url=urls[0],
api_key=api_key,
fallback_urls=urls[1:],
)
dest.write_bytes(data)
text, method = extract_text_from_file(dest)
except Exception as exc:
file_results.append(
{
"filename": name,
"method": "error",
"text": "",
"path": str(dest),
"error": str(exc)[:200],
}
)
continue
file_results.append(
{
"filename": name,
"method": method,
"text": text,
"path": str(dest),
}
)
if text:
combined_text_parts.append(text)
combined = "\n\n".join(combined_text_parts)
proposed = suggest_subject_from_text(combined, original_subject=subj)
row: dict[str, Any] = {
"issue_id": iid,
"old_subject": subj,
"proposed_subject": proposed,
"files": [
{
"filename": f["filename"],
"method": f["method"],
"chars": len(f.get("text") or ""),
"path": f["path"],
}
for f in file_results
],
"drafts": {},
}
if args.queue_subject and proposed and proposed != subj:
if args.apply_subject:
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(
args.base_url,
allow_prod_writes=allow_prod_writes_enabled(args),
)
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
client.update_issue(
iid,
{"subject": proposed},
impersonate_user=args.impersonate_user,
)
row["drafts"]["subject_applied"] = True
# Keep local cache in sync with the live ticket we just mutated
try:
if refresh_cached_issue:
c = TicketCache(Path(args.cache_dir), args.project)
refresh_cached_issue(c, client, int(iid))
except Exception:
pass
elif add_draft is not None:
path = add_draft(
Path(args.cache_dir),
issue_id=iid,
kind=KIND_FIELDS,
fields={"subject": proposed},
project=args.project,
base_url=args.base_url,
)
row["drafts"]["subject_draft_id"] = path.stem
if not args.json:
print_draft_queued(
path,
kind=KIND_FIELDS,
issue_id=iid,
base_url=args.base_url,
emails_customer=False,
)
if args.queue_note and build_ocr_note is not None and add_draft is not None:
note_body = build_ocr_note(
issue_id=iid,
files=file_results,
proposed_subject=proposed,
)
path = add_draft(
Path(args.cache_dir),
issue_id=iid,
kind=KIND_INTERNAL_NOTE,
body=note_body,
private=True,
project=args.project,
base_url=args.base_url,
)
row["drafts"]["note_draft_id"] = path.stem
if not args.json:
print_draft_queued(
path,
kind=KIND_INTERNAL_NOTE,
issue_id=iid,
base_url=args.base_url,
emails_customer=False,
)
results.append(row)
if not args.json:
print(
f"#{iid}\told={subj!r}\n proposed={proposed!r}\n"
f" files={len(file_results)} drafts={row['drafts']}"
)
if args.json:
print(json.dumps(results, indent=2, sort_keys=True))
else:
print(f"ocr_rename_done tickets={len(results)}", file=sys.stderr)
return 0
if args.command == "control-tag":
if control_tag_add is None or control_tag_list is None:
raise RedmineError("Control-tag helpers unavailable (redmine_ticket_control_tags.py).")
cmd = args.control_tag_command
if cmd is None:
raise RedmineError(
"control-tag requires subcommand: add|remove|list|show|for-issue|suggest|wipe"
)
cache_path = Path(args.cache_dir)
if cmd == "add":
detail = control_tag_add(
cache_path,
tag=args.tag,
issue_id=int(args.issue_id),
properties=list(args.properties or []),
annotation=args.annotation or "",
)
print(f"tagged #{args.issue_id} with local control-tag {args.tag!r}")
print(json.dumps(detail, indent=2, sort_keys=True))
return 0
if cmd == "remove":
if control_tag_remove is None:
raise RedmineError("Control-tag remove helper unavailable.")
ok = control_tag_remove(cache_path, tag=args.tag, issue_id=int(args.issue_id))
if not ok:
raise RedmineError(f"No local tag {args.tag!r} on #{args.issue_id}")
print(f"removed local control-tag {args.tag!r} from #{args.issue_id}")
return 0
if cmd == "list":
for row in control_tag_list(cache_path):
print(f"{row['tag']}\tcount={row['ticket_count']}\tids={row['issue_ids'][:20]}")
return 0
if cmd == "show":
ids = tickets_for_tag(cache_path, args.tag) if tickets_for_tag else []
for iid in ids:
print(iid)
return 0
if cmd == "for-issue":
tags = tags_for_issue(cache_path, int(args.issue_id)) if tags_for_issue else []
print(",".join(tags) if tags else "")
return 0
if cmd == "suggest":
if filter_by_query is None or control_tag_add is None:
raise RedmineError("Suggest requires query + control-tag helpers.")
where = list(args.where or [])
if not where:
# default: subject includes tag name
where = [f'subject.includes("{args.tag}")']
issues = list(cache.iter_cached_issues())
try:
candidates = filter_by_query(issues, expressions=where)
except QueryError as exc:
raise RedmineError(f"Invalid --where for suggest: {exc}") from exc
candidates = select_issues(
candidates,
order="newest-updated-first",
max_tickets=args.max_tickets,
)
already = set(tickets_for_tag(cache_path, args.tag) if tickets_for_tag else [])
proposed = [i for i in candidates if i.get("id") is not None and int(i["id"]) not in already]
print(
f"suggest tag={args.tag!r} candidates={len(candidates)} "
f"already_tagged={len(already)} new={len(proposed)} apply={bool(args.apply)}"
)
for issue in proposed:
iid = int(issue["id"])
subj = str(issue.get("subject") or "").replace("\n", " ")[:80]
print(f"#{iid}\t{subj}")
if args.apply:
control_tag_add(
cache_path,
tag=args.tag,
issue_id=iid,
properties=list(args.properties or ["subject"]),
annotation=f"auto-suggest where={where}",
)
if args.apply:
print(f"applied_local_tags={len(proposed)}")
return 0
if cmd == "export":
if export_control_tag_jsonl is None:
raise RedmineError("Export helper unavailable (redmine_ticket_control_export.py).")
from redmine_ticket_control_tags import load_store as _load_store
store = _load_store(cache_path)
issues_by_id = {}
for issue in cache.iter_cached_issues():
if issue.get("id") is not None:
issues_by_id[int(issue["id"])] = issue
n = export_control_tag_jsonl(
cache_dir=cache_path,
tags_payload=store,
issues_by_id=issues_by_id,
tag=args.tag,
out_path=Path(args.out),
)
print(f"exported_records={n} path={args.out}")
return 0
if cmd == "wipe":
if not getattr(args, "i_understand_this_wipes_local_control_tags", False):
raise RedmineError("wipe requires confirmation flag")
path = control_tag_wipe(cache_path) if control_tag_wipe else None
print(f"wiped local control-tag store: {path}")
return 0
raise RedmineError(f"Unknown control-tag subcommand: {cmd}")
if args.command == "customer-email":
# Flag is required=True on parser; keep explicit checks for safety.
if not getattr(args, "i_understand_this_emails_the_customer", False):
raise RedmineError(
"customer-email requires --i-understand-this-emails-the-customer. "
"This command is for emailing the customer, not internal notes. "
"Use `note` for staff-only journal notes."
)
body = resolve_body_text(args)
print("ACTION: CUSTOMER EMAIL (Helpdesk)")
print(" emails_customer: YES — this will email the customer when applied")
print(f" issue_id: {args.issue_id}")
print(" private: NOT ALLOWED on this path")
if not args.apply:
if add_draft is None:
raise RedmineError("Draft helpers unavailable.")
path = add_draft(
Path(args.cache_dir),
issue_id=int(args.issue_id),
kind=KIND_CUSTOMER_EMAIL,
body=body,
private=False,
project=args.project,
base_url=args.base_url,
)
print_draft_queued(
path,
kind=KIND_CUSTOMER_EMAIL,
issue_id=int(args.issue_id),
base_url=args.base_url,
emails_customer=True,
)
return 0
if assert_write_target_allowed is None:
raise RedmineError("Write safety helpers unavailable.")
try:
assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args))
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
client = client_from_args(args)
result = client.send_customer_helpdesk_email(
int(args.issue_id),
body,
status_id=getattr(args, "status_id", None),
to_address=getattr(args, "to_address", None),
cc_address=getattr(args, "cc_address", None),
impersonate_user=args.impersonate_user,
)
print(f"sent customer Helpdesk email for #{args.issue_id}")
print(json.dumps(result, indent=2, sort_keys=True) if result else "(empty response)")
return 0
if args.command == "draft":
if add_draft is None or list_drafts is None or cancel_draft is None:
raise RedmineError("Draft helpers unavailable (redmine_ticket_drafts.py).")
if args.draft_command is None:
raise RedmineError("draft requires a subcommand: add | list | cancel | apply")
if args.draft_command == "add":
body = resolve_body_text(args)
try:
kind = normalize_kind(args.kind) if normalize_kind else args.kind
except ValueError as exc:
raise RedmineError(str(exc)) from exc
if kind == KIND_CUSTOMER_EMAIL:
print("WARNING: queuing CUSTOMER EMAIL draft (will email when applied).")
else:
print("Queuing INTERNAL NOTE draft (will NOT email when applied).")
try:
path = add_draft(
Path(args.cache_dir),
issue_id=int(args.issue_id),
kind=str(kind),
body=body,
private=bool(args.private),
project=args.project,
base_url=args.base_url,
)
except ValueError as exc:
raise RedmineError(str(exc)) from exc
print_draft_queued(
path,
kind=str(kind),
issue_id=int(args.issue_id),
base_url=args.base_url,
emails_customer=(str(kind) == KIND_CUSTOMER_EMAIL),
)
return 0
if args.draft_command == "list":
status = None if args.all else "pending"
for draft in list_drafts(Path(args.cache_dir), status=status):
preview = (draft.get("body") or "").replace("\n", " ")[:80]
emails = "EMAILS_CUSTOMER" if draft.get("kind") == KIND_CUSTOMER_EMAIL or draft.get("emails_customer") else "no-email"
target = (
f"contact#{draft.get('contact_id')}"
if draft.get("contact_id") is not None
else f"#{draft.get('issue_id')}"
)
print(
f"{draft.get('id')}\t{draft.get('status')}\t{target}\t"
f"{draft.get('kind')}\t{emails}\tbase_url={draft.get('base_url') or ''}\t"
f"private={draft.get('private')}\t{preview}"
)
return 0
if args.draft_command == "cancel":
if args.all_pending:
n = cancel_all_pending(Path(args.cache_dir)) if cancel_all_pending else 0
print(f"cancelled_pending={n}")
return 0
if not args.draft_id:
raise RedmineError("draft cancel requires draft_id or --all-pending")
ok = cancel_draft(Path(args.cache_dir), args.draft_id)
if not ok:
raise RedmineError(f"Draft not found: {args.draft_id}")
print(f"cancelled {args.draft_id}")
return 0
if args.draft_command == "apply":
if load_draft is None or mark_draft_applied is None or assert_write_target_allowed is None:
raise RedmineError("Draft apply helpers unavailable.")
try:
_path, draft = load_draft(Path(args.cache_dir), args.draft_id)
except FileNotFoundError as exc:
raise RedmineError(str(exc)) from exc
if draft.get("status") != "pending":
raise RedmineError(f"Draft {draft.get('id')} is not pending (status={draft.get('status')})")
kind = normalize_kind(str(draft.get("kind"))) if normalize_kind else str(draft.get("kind"))
apply_base = resolve_apply_base_url(args, draft)
print(f"Applying draft {draft.get('id')}")
print(f" kind: {kind_label(kind) if kind_label else kind}")
if draft.get("issue_id") is not None:
print(f" issue_id: {draft.get('issue_id')}")
if draft.get("contact_id") is not None:
print(f" contact_id: {draft.get('contact_id')}")
print(f" base_url: {apply_base}")
print(f" emails_customer: {kind == KIND_CUSTOMER_EMAIL}")
try:
assert_write_target_allowed(
apply_base,
allow_prod_writes=allow_prod_writes_enabled(args),
)
except RuntimeError as exc:
raise RedmineError(str(exc)) from exc
# Point client at draft host
args.base_url = apply_base
client = client_from_args(args)
try:
result = apply_draft_to_redmine(
client,
draft,
impersonate_user=args.impersonate_user,
allow_customer_email=bool(args.i_understand_this_emails_the_customer),
)
mark_draft_applied(Path(args.cache_dir), str(draft["id"]), result=result)
# Refresh local cache immediately so browse / tui / listings see the live state
# (status, journals, updated_on, etc.). This matches "update existing cache files on material change".
try:
issue_id = draft.get("issue_id")
if issue_id and refresh_cached_issue:
c = TicketCache(Path(args.cache_dir), args.project)
refresh_cached_issue(c, client, int(issue_id))
except Exception:
pass
except Exception as exc:
if mark_draft_error is not None:
mark_draft_error(Path(args.cache_dir), str(draft["id"]), str(exc))
raise
print(f"applied draft {draft.get('id')}: {json.dumps(result, sort_keys=True)}")
return 0
raise RedmineError(f"Unknown draft subcommand: {args.draft_command}")
if args.command == "spam-check":
results = run_spam_check(cache, apply=args.apply, threshold=args.threshold)
print_spam_results(results)
print(f"Spam check complete: checked={len(results)} apply={args.apply}")
if not args.apply:
print("Dry run only. Re-run with --apply to record spam-check idempotency markers.")
return 0
if args.command == "close-if-spam":
client = client_from_args(args)
plans = run_close_if_spam(
client,
cache,
close_status_id=args.close_status_id,
contact_ticket_limit=args.contact_ticket_limit,
apply=args.apply,
threshold=args.threshold,
impersonate_user=args.impersonate_user,
)
print_close_if_spam_plans(plans)
print(f"Close-if-spam complete: planned={len(plans)} apply={args.apply}")
if not args.apply:
print("Dry run only. Re-run with --apply to close Redmine issues and record idempotency markers.")
return 0
if args.command == "tag-maybe-customer":
client = client_from_args(args)
results = run_tag_maybe_customer_contacts(
client,
cache,
apply=args.apply,
tag_name=args.tag,
impersonate_user=args.impersonate_user,
)
for result in results:
print(
f"contact_id={result['contact_id']}\t"
f"source_issue_id={result['source_issue_id']}\t"
f"tag_list={json.dumps(result['tag_list'])}"
)
print(f"Tag-maybe-customer complete: planned={len(results)} apply={args.apply}")
if not args.apply:
print("Dry run only. Re-run with --apply to tag Redmine contacts.")
return 0
if args.command == "decompose":
issue = load_issue_for_decompose(args, cache)
plan = decompose_issue_dry_run(issue)
if args.format == "markdown":
print(render_decomposition_markdown(plan))
else:
print(json.dumps(plan, indent=2, sort_keys=True))
return 0
print(f"error: unhandled command {getattr(args, 'command', None)!r}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())