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

317 lines
10 KiB
Python

#!/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)