#!/usr/bin/env python3 """Mechanical triage scoring for the Timmy dev loop. Reads open issues from Gitea, scores them on scope/acceptance/alignment, writes a ranked queue to .loop/queue.json. No LLM calls — pure heuristics. Run: python3 scripts/triage_score.py Env: GITEA_TOKEN (or reads ~/.hermes/gitea_token) GITEA_API (default: http://localhost:3000/api/v1) REPO_SLUG (default: rockachopa/Timmy-time-dashboard) """ from __future__ import annotations import json import os import re import sys from datetime import datetime, timezone from pathlib import Path # ── Config ────────────────────────────────────────────────────────────── GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" REPO_ROOT = Path(__file__).resolve().parent.parent QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json" RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl" QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json" CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl" # Minimum score to be considered "ready" READY_THRESHOLD = 5 # How many recent cycle retros to check for quarantine QUARANTINE_LOOKBACK = 20 # ── Helpers ───────────────────────────────────────────────────────────── def get_token() -> str: token = os.environ.get("GITEA_TOKEN", "").strip() if not token and TOKEN_FILE.exists(): token = TOKEN_FILE.read_text().strip() if not token: print("[triage] ERROR: No Gitea token found", file=sys.stderr) sys.exit(1) return token def api_get(path: str, token: str) -> list | dict: """Minimal HTTP GET using urllib (no dependencies).""" import urllib.request url = f"{GITEA_API}/repos/{REPO_SLUG}/{path}" req = urllib.request.Request(url, headers={ "Authorization": f"token {token}", "Accept": "application/json", }) with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read()) def load_quarantine() -> dict: """Load quarantined issues {issue_num: {reason, quarantined_at, failures}}.""" if QUARANTINE_FILE.exists(): try: return json.loads(QUARANTINE_FILE.read_text()) except (json.JSONDecodeError, OSError): pass return {} def save_quarantine(q: dict) -> None: QUARANTINE_FILE.parent.mkdir(parents=True, exist_ok=True) QUARANTINE_FILE.write_text(json.dumps(q, indent=2) + "\n") def load_cycle_failures() -> dict[int, int]: """Count failures per issue from recent cycle retros.""" failures: dict[int, int] = {} if not CYCLE_RETRO_FILE.exists(): return failures lines = CYCLE_RETRO_FILE.read_text().strip().splitlines() for line in lines[-QUARANTINE_LOOKBACK:]: try: entry = json.loads(line) if not entry.get("success", True): issue = entry.get("issue") if issue: failures[issue] = failures.get(issue, 0) + 1 except (json.JSONDecodeError, KeyError): continue return failures # ── Scoring ───────────────────────────────────────────────────────────── # Patterns that indicate file/function specificity FILE_PATTERNS = re.compile( r"(?:src/|tests/|scripts/|\.py|\.html|\.js|\.yaml|\.toml|\.sh)", re.IGNORECASE ) FUNCTION_PATTERNS = re.compile( r"(?:def |class |function |method |`\w+\(\)`)", re.IGNORECASE ) # Patterns that indicate acceptance criteria ACCEPTANCE_PATTERNS = re.compile( r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria" r"|pass(?:es|ing)|fail(?:s|ing)|return(?:s)?|raise(?:s)?)", re.IGNORECASE, ) TEST_PATTERNS = re.compile( r"(?:tox|pytest|test_\w+|\.test\.|assert\s)", re.IGNORECASE ) # Tags in issue titles TAG_PATTERN = re.compile(r"\[([^\]]+)\]") # Priority labels / tags BUG_TAGS = {"bug", "broken", "crash", "error", "fix", "regression", "hotfix"} FEATURE_TAGS = {"feature", "feat", "enhancement", "capability", "timmy-capability"} REFACTOR_TAGS = {"refactor", "cleanup", "tech-debt", "optimization", "perf"} META_TAGS = {"philosophy", "soul-gap", "discussion", "question", "rfc"} LOOP_TAG = "loop-generated" def extract_tags(title: str, labels: list[str]) -> set[str]: """Pull tags from [bracket] notation in title + Gitea labels.""" tags = set() for match in TAG_PATTERN.finditer(title): tags.add(match.group(1).lower().strip()) for label in labels: tags.add(label.lower().strip()) return tags def score_scope(title: str, body: str, tags: set[str]) -> int: """0-3: How well-scoped is this issue?""" text = f"{title}\n{body}" score = 0 # Mentions specific files? if FILE_PATTERNS.search(text): score += 1 # Mentions specific functions/classes? if FUNCTION_PATTERNS.search(text): score += 1 # Short, focused title (not a novel)? clean_title = TAG_PATTERN.sub("", title).strip() if len(clean_title) < 80: score += 1 # Philosophy/meta issues are inherently unscoped for dev work if tags & META_TAGS: score = max(0, score - 2) return min(3, score) def score_acceptance(title: str, body: str, tags: set[str]) -> int: """0-3: Does this have clear acceptance criteria?""" text = f"{title}\n{body}" score = 0 # Has acceptance-related language? matches = len(ACCEPTANCE_PATTERNS.findall(text)) if matches >= 3: score += 2 elif matches >= 1: score += 1 # Mentions specific tests? if TEST_PATTERNS.search(text): score += 1 # Has a "## Problem" + "## Solution" or similar structure? if re.search(r"##\s*(problem|solution|expected|actual|steps)", body, re.IGNORECASE): score += 1 # Philosophy issues don't have testable criteria if tags & META_TAGS: score = max(0, score - 1) return min(3, score) def score_alignment(title: str, body: str, tags: set[str]) -> int: """0-3: How aligned is this with the north star?""" score = 0 # Bug on main = highest priority if tags & BUG_TAGS: score += 3 return min(3, score) # Refactors that improve code health if tags & REFACTOR_TAGS: score += 2 # Features that grow Timmy's capabilities if tags & FEATURE_TAGS: score += 2 # Loop-generated issues get a small boost (the loop found real problems) if LOOP_TAG in tags: score += 1 # Philosophy issues are important but not dev-actionable if tags & META_TAGS: score = 0 return min(3, score) def score_issue(issue: dict) -> dict: """Score a single issue. Returns enriched dict.""" title = issue.get("title", "") body = issue.get("body", "") or "" labels = [l["name"] for l in issue.get("labels", [])] tags = extract_tags(title, labels) number = issue["number"] scope = score_scope(title, body, tags) acceptance = score_acceptance(title, body, tags) alignment = score_alignment(title, body, tags) total = scope + acceptance + alignment # Determine issue type if tags & BUG_TAGS: issue_type = "bug" elif tags & FEATURE_TAGS: issue_type = "feature" elif tags & REFACTOR_TAGS: issue_type = "refactor" elif tags & META_TAGS: issue_type = "philosophy" else: issue_type = "unknown" # Extract mentioned files from body files = list(set(re.findall(r"(?:src|tests|scripts)/[\w/.]+\.(?:py|html|js|yaml)", body))) return { "issue": number, "title": TAG_PATTERN.sub("", title).strip(), "type": issue_type, "score": total, "scope": scope, "acceptance": acceptance, "alignment": alignment, "tags": sorted(tags), "files": files[:10], "ready": total >= READY_THRESHOLD, } # ── Quarantine ────────────────────────────────────────────────────────── def update_quarantine(scored: list[dict]) -> list[dict]: """Auto-quarantine issues that have failed >= 2 times. Returns filtered list.""" failures = load_cycle_failures() quarantine = load_quarantine() now = datetime.now(timezone.utc).isoformat() filtered = [] for item in scored: num = item["issue"] fail_count = failures.get(num, 0) str_num = str(num) if fail_count >= 2 and str_num not in quarantine: quarantine[str_num] = { "reason": f"Failed {fail_count} times in recent cycles", "quarantined_at": now, "failures": fail_count, } print(f"[triage] QUARANTINED #{num}: failed {fail_count} times") continue if str_num in quarantine: print(f"[triage] Skipping #{num} (quarantined)") continue filtered.append(item) save_quarantine(quarantine) return filtered # ── Main ──────────────────────────────────────────────────────────────── def run_triage() -> list[dict]: token = get_token() # Fetch all open issues (paginate) page = 1 all_issues: list[dict] = [] while True: batch = api_get(f"issues?state=open&limit=50&page={page}&type=issues", token) if not batch: break all_issues.extend(batch) if len(batch) < 50: break page += 1 print(f"[triage] Fetched {len(all_issues)} open issues") # Score each scored = [score_issue(i) for i in all_issues] # Auto-quarantine repeat failures scored = update_quarantine(scored) # Sort: ready first, then by score descending, bugs always on top def sort_key(item: dict) -> tuple: return ( 0 if item["type"] == "bug" else 1, -item["score"], item["issue"], ) scored.sort(key=sort_key) # Write queue (ready items only) ready = [s for s in scored if s["ready"]] not_ready = [s for s in scored if not s["ready"]] QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n") # Write retro entry retro_entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "total_open": len(all_issues), "scored": len(scored), "ready": len(ready), "not_ready": len(not_ready), "top_issue": ready[0]["issue"] if ready else None, "quarantined": len(load_quarantine()), } RETRO_FILE.parent.mkdir(parents=True, exist_ok=True) with open(RETRO_FILE, "a") as f: f.write(json.dumps(retro_entry) + "\n") # Summary print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)}") for item in ready[:5]: flag = "🐛" if item["type"] == "bug" else "✦" print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}") if not_ready: print(f"[triage] Low-scoring ({len(not_ready)}):") for item in not_ready[:3]: print(f" #{item['issue']} score={item['score']} {item['title'][:50]}") return ready if __name__ == "__main__": run_triage()