317 lines
10 KiB
Python
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)
|