#!/usr/bin/env python3 """ Lightweight Redmine triage for "tickets that truly need attention". Designed for fast iteration. Run manually or from cron/magitek. Usage examples: ./bin/rtl triage ./bin/rtl triage --open-only --max 30 --jsonl ./bin/rtl triage --queue-triage-note # adds internal notes with context for the top items Output is human-first + machine-friendly so it can feed other agents. """ from __future__ import annotations import argparse import json from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional # Reuse the existing power-user infrastructure try: from redmine_tickets import ( TicketCache, select_cached_issues_for_cli, ) except Exception as e: raise RuntimeError(f"Could not import redmine power client: {e}") from e try: from redmine_ticket_entities import extract_entities_from_issue, format_entities_line except Exception: extract_entities_from_issue = None format_entities_line = None try: from redmine_ticket_drafts import add_draft, KIND_INTERNAL_NOTE except Exception: add_draft = None KIND_INTERNAL_NOTE = "internal_note" @dataclass class TriageScore: issue_id: int score: float reasons: list[str] age_hours: float last_update: str status: str assigned: str subject: str context_block: str # ready-to-paste Textile / Markdown context def _parse_dt(s: Optional[str]) -> Optional[datetime]: if not s: return None for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): try: dt = datetime.strptime(s, fmt) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except Exception: continue return None def _hours_since(dt: Optional[datetime]) -> float: if not dt: return 999.0 now = datetime.now(timezone.utc) return max(0.0, (now - dt).total_seconds() / 3600.0) def _get_status(issue: dict) -> str: st = issue.get("status") or {} if isinstance(st, dict): return st.get("name", "") or "" return str(st) def _get_assigned(issue: dict) -> str: a = issue.get("assigned_to") or {} if isinstance(a, dict): return a.get("name") or a.get("login") or "" return str(a or "") def _last_journal_age_and_type(issue: dict) -> tuple[float, str]: journals = issue.get("journals") or [] if not journals: return 999.0, "no-journals" try: last = journals[-1] created = _parse_dt(last.get("created_on")) age = _hours_since(created) notes = last.get("notes") or "" user = (last.get("user") or {}).get("name", "") is_our_note = "internal" in str(last).lower() or "rebot" in user.lower() typ = "our-internal" if is_our_note else "customer-or-other" return age, typ except Exception: return 999.0, "parse-err" def score_issue(issue: dict, now: datetime | None = None) -> TriageScore: iid = int(issue.get("id", 0)) subject = (issue.get("subject") or "")[:80] status = _get_status(issue) assigned = _get_assigned(issue) updated = _parse_dt(issue.get("updated_on")) age_h = _hours_since(updated) last_j_age, last_j_type = _last_journal_age_and_type(issue) score = 0.0 reasons: list[str] = [] # Core staleness if age_h > 48: score += 40 reasons.append(f"stale>{int(age_h)}h") elif age_h > 24: score += 25 reasons.append(f"stale>{int(age_h)}h") elif age_h > 8: score += 12 reasons.append(f"stale>{int(age_h)}h") # Status pressure st_lower = status.lower() if "new" in st_lower: score += 30 reasons.append("status:new") elif "feedback" in st_lower: score += 28 reasons.append("status:feedback") elif "progress" in st_lower and age_h > 12: score += 15 reasons.append("progress+stale") # Journal gap (customer spoke, we didn't reply recently) if last_j_type != "our-internal" and last_j_age < 72: if last_j_age < 4: score += 20 reasons.append("recent-customer-msg") elif last_j_age < 24: score += 10 reasons.append("customer-msg-no-our-reply") # Unassigned pressure if not assigned or assigned.lower() in ("", "none", "nobody"): if "new" in st_lower or "feedback" in st_lower: score += 15 reasons.append("unassigned+open") # Keyword boost (tunable) text = (subject + " " + str(issue.get("description", ""))).lower() for kw, pts in [ ("urgent", 18), ("complaint", 15), ("delay", 10), ("stuck", 12), ("not received", 12), ("missing", 8), ("wrong", 8), ]: if kw in text: score += pts reasons.append(f"kw:{kw}") # Entity / NMI context is a positive signal (we have something to act on) if extract_entities_from_issue: try: ents = extract_entities_from_issue(issue) if ents: score += 8 reasons.append("has-entities") except Exception: pass # Build compact context block (Textile-friendly for internal notes) ctx_lines = [ f"h4. Triage context for #{iid}", f"*Status*: {status} | *Assigned*: {assigned or '—'} | *Updated*: {issue.get('updated_on', '')[:16]} ({int(age_h)}h ago)", ] if extract_entities_from_issue: try: ents = extract_entities_from_issue(issue) line = format_entities_line(ents) if format_entities_line and ents else "" if line: ctx_lines.append(f"*Entities*: {line}") except Exception: pass # Last journal teaser journals = issue.get("journals") or [] if journals: last = journals[-1] notes = (last.get("notes") or "")[:200].replace("\n", " ") user = (last.get("user") or {}).get("name", "?") ctx_lines.append(f"*Last journal* ({user}): {notes}...") ctx_lines.append("") ctx_lines.append("Suggested: review, add context note, or assign + paint status.") context_block = "\n".join(ctx_lines) return TriageScore( issue_id=iid, score=round(score, 1), reasons=reasons, age_hours=round(age_h, 1), last_update=str(issue.get("updated_on", ""))[:16], status=status, assigned=assigned, subject=subject, context_block=context_block, ) def run_triage(args: argparse.Namespace) -> int: cache = TicketCache(args.cache_dir, args.project) # Build a light filter set that favors things that need attention filt = argparse.Namespace() filt.project = args.project filt.cache_dir = args.cache_dir filt.base_url = args.base_url filt.allow_prod_writes = getattr(args, "allow_prod_writes", False) filt.open_only = getattr(args, "open_only", True) filt.max_tickets = getattr(args, "max_tickets", 200) filt.updated_since = None filt.updated_before = None filt.ids = None filt.where = [] filt.control_tag = None filt.has_attachments = False issues = select_cached_issues_for_cli(cache, filt) scored: list[TriageScore] = [] for iss in issues: try: ts = score_issue(iss) if ts.score > 0.5: # only things with real signal scored.append(ts) except Exception: continue scored.sort(key=lambda x: (-x.score, x.age_hours)) if args.max: scored = scored[: args.max] if args.jsonl: for s in scored: print(json.dumps({ "id": s.issue_id, "score": s.score, "reasons": s.reasons, "subject": s.subject, "status": s.status, "assigned": s.assigned, })) return 0 # Human output print(f"Triage (project={args.project}, open-ish, scored for attention)\n") for s in scored: print(f"#{s.issue_id} score={s.score:5.1f} {s.status[:12]:<12} {s.assigned[:10]:<10} {s.subject}") print(f" age={s.age_hours:5.1f}h reasons={','.join(s.reasons[:4])}") if args.show_context: print(s.context_block) print("") if args.queue_triage_note and add_draft: for s in scored[: args.queue_limit]: try: add_draft( args.cache_dir, issue_id=s.issue_id, kind=KIND_INTERNAL_NOTE, body=s.context_block, private=True, project=args.project, ) print(f"Queued triage note for #{s.issue_id}") except Exception as e: print(f"Failed to queue for #{s.issue_id}: {e}") print(f"\nTotal high-signal: {len(scored)}") return 0 def add_triage_parser(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser("triage", help="Score and list tickets that need real attention (fast triage force-multiplier)") p.add_argument("--project", default="customer-service") p.add_argument("--cache-dir", default=Path(".cache/redmine_tickets"), type=Path) p.add_argument("--base-url", default="https://rm.ldrprep.com") p.add_argument("--open-only", action="store_true", default=True, help="Only open-ish tickets (default true)") p.add_argument("--max", type=int, default=25, help="Max tickets to show") p.add_argument("--max-tickets", type=int, default=300, help="How many to pull from cache before scoring") p.add_argument("--show-context", action="store_true", help="Print the ready-to-paste context block for each") p.add_argument("--jsonl", action="store_true", help="Machine readable output") p.add_argument("--queue-triage-note", action="store_true", help="Queue internal note with the triage context (uses draft system)") p.add_argument("--queue-limit", type=int, default=10, help="How many top tickets to queue notes for") p.add_argument("--allow-prod-writes", action="store_true") p.set_defaults(func=run_triage) if __name__ == "__main__": ap = argparse.ArgumentParser() add_triage_parser(ap) args = ap.parse_args() run_triage(args)