4887 lines
200 KiB
Python
Executable File
4887 lines
200 KiB
Python
Executable File
#!/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}
|
||
{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"}:
|
||
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",
|
||
}
|
||
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:
|
||
raise RedmineError("Set REDMINE_API_KEY in the environment.")
|
||
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(
|
||
"--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).",
|
||
)
|
||
|
||
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
|
||
|
||
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
|
||
return 1
|
||
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|