From dfb804b76a9475c78ea3b347500cbfc9535b5203 Mon Sep 17 00:00:00 2001 From: kimi Date: Tue, 24 Mar 2026 16:20:49 -0400 Subject: [PATCH] fix: triage_score.py merge queue instead of overwrite, add exclusions support - Changed queue write logic to MERGE instead of OVERWRITE: - Load existing queue.json if it exists - Add only NEW ready issues not already in queue - Preserve existing queue items (deep triage cuts are sticky) - Added queue_exclusions.json support: - load_exclusions() / save_exclusions() functions - Excluded issues are filtered before queue processing - Deep triage can populate this file to persist removals - Updated summary output to show existing/new item counts - Added comprehensive tests for merge logic and exclusions Fixes #1463 --- .loop/queue_exclusions.json | 1 + scripts/triage_score.py | 52 +++++- tests/scripts/test_triage_score_validation.py | 172 ++++++++++++++++++ 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 .loop/queue_exclusions.json diff --git a/.loop/queue_exclusions.json b/.loop/queue_exclusions.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.loop/queue_exclusions.json @@ -0,0 +1 @@ +[] diff --git a/scripts/triage_score.py b/scripts/triage_score.py index e2ffdba8..aa0d1009 100644 --- a/scripts/triage_score.py +++ b/scripts/triage_score.py @@ -45,6 +45,7 @@ QUEUE_BACKUP_FILE = REPO_ROOT / ".loop" / "queue.json.bak" RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl" QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json" CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl" +EXCLUSIONS_FILE = REPO_ROOT / ".loop" / "queue_exclusions.json" # Minimum score to be considered "ready" READY_THRESHOLD = 5 @@ -85,6 +86,24 @@ def load_quarantine() -> dict: return {} +def load_exclusions() -> list[int]: + """Load excluded issue numbers (sticky removals from deep triage).""" + if EXCLUSIONS_FILE.exists(): + try: + data = json.loads(EXCLUSIONS_FILE.read_text()) + if isinstance(data, list): + return [int(x) for x in data if isinstance(x, int) or (isinstance(x, str) and x.isdigit())] + except (json.JSONDecodeError, OSError, ValueError): + pass + return [] + + +def save_exclusions(exclusions: list[int]) -> None: + """Save excluded issue numbers to persist deep triage removals.""" + EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + EXCLUSIONS_FILE.write_text(json.dumps(sorted(set(exclusions)), indent=2) + "\n") + + 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") @@ -329,6 +348,12 @@ def run_triage() -> list[dict]: # Auto-quarantine repeat failures scored = update_quarantine(scored) + # Load exclusions (sticky removals from deep triage) + exclusions = load_exclusions() + + # Filter out excluded issues - they never get re-added + scored = [s for s in scored if s["issue"] not in exclusions] + # Sort: ready first, then by score descending, bugs always on top def sort_key(item: dict) -> tuple: return ( @@ -339,10 +364,29 @@ def run_triage() -> list[dict]: scored.sort(key=sort_key) - # Write queue (ready items only) - ready = [s for s in scored if s["ready"]] + # Get ready items from current scoring run + newly_ready = [s for s in scored if s["ready"]] not_ready = [s for s in scored if not s["ready"]] + # MERGE logic: preserve existing queue, only add new issues + existing_queue = [] + if QUEUE_FILE.exists(): + try: + existing_queue = json.loads(QUEUE_FILE.read_text()) + if not isinstance(existing_queue, list): + existing_queue = [] + except (json.JSONDecodeError, OSError): + existing_queue = [] + + # Build set of existing issue numbers + existing_issues = {item["issue"] for item in existing_queue if isinstance(item, dict) and "issue" in item} + + # Add only new issues that aren't already in the queue and aren't excluded + new_items = [s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions] + + # Merge: existing items + new items + ready = existing_queue + new_items + # Save backup before writing (if current file exists and is valid) if QUEUE_FILE.exists(): try: @@ -351,7 +395,7 @@ def run_triage() -> list[dict]: except (json.JSONDecodeError, OSError): pass # Current file is corrupt, don't overwrite backup - # Write new queue file + # Write merged queue file QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n") @@ -390,7 +434,7 @@ def run_triage() -> list[dict]: f.write(json.dumps(retro_entry) + "\n") # Summary - print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)}") + print(f"[triage] Ready: {len(ready)} | Not ready: {len(not_ready)} | Existing: {len(existing_issues)} | New: {len(new_items)}") for item in ready[:5]: flag = "🐛" if item["type"] == "bug" else "✦" print(f" {flag} #{item['issue']} score={item['score']} {item['title'][:60]}") diff --git a/tests/scripts/test_triage_score_validation.py b/tests/scripts/test_triage_score_validation.py index 882ac43c..20b529eb 100644 --- a/tests/scripts/test_triage_score_validation.py +++ b/tests/scripts/test_triage_score_validation.py @@ -157,3 +157,175 @@ def test_backup_path_configuration(): assert ts.QUEUE_BACKUP_FILE.parent == ts.QUEUE_FILE.parent assert ts.QUEUE_BACKUP_FILE.name == "queue.json.bak" assert ts.QUEUE_FILE.name == "queue.json" + + +def test_exclusions_file_path(): + """Ensure exclusions file path is properly configured.""" + assert ts.EXCLUSIONS_FILE.name == "queue_exclusions.json" + assert ts.EXCLUSIONS_FILE.parent == ts.REPO_ROOT / ".loop" + + +def test_load_exclusions_empty_file(tmp_path): + """Loading from empty/non-existent exclusions file returns empty list.""" + assert ts.load_exclusions() == [] + + +def test_load_exclusions_with_data(tmp_path, monkeypatch): + """Loading exclusions returns list of integers.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.EXCLUSIONS_FILE.write_text("[123, 456, 789]") + assert ts.load_exclusions() == [123, 456, 789] + + +def test_load_exclusions_with_strings(tmp_path, monkeypatch): + """Loading exclusions handles string numbers gracefully.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.EXCLUSIONS_FILE.write_text('["100", 200, "invalid", 300]') + assert ts.load_exclusions() == [100, 200, 300] + + +def test_load_exclusions_corrupt_file(tmp_path, monkeypatch): + """Loading from corrupt exclusions file returns empty list.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.EXCLUSIONS_FILE.write_text("not valid json") + assert ts.load_exclusions() == [] + + +def test_save_exclusions(tmp_path, monkeypatch): + """Saving exclusions writes sorted unique integers.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.save_exclusions([300, 100, 200, 100]) # includes duplicate + assert json.loads(ts.EXCLUSIONS_FILE.read_text()) == [100, 200, 300] + + +def test_merge_preserves_existing_queue(tmp_path, monkeypatch): + """Merge logic preserves existing queue items and only adds new ones.""" + monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json") + monkeypatch.setattr(ts, "QUEUE_BACKUP_FILE", tmp_path / "queue.json.bak") + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + monkeypatch.setattr(ts, "RETRO_FILE", tmp_path / "retro" / "triage.jsonl") + monkeypatch.setattr(ts, "QUARANTINE_FILE", tmp_path / "quarantine.json") + monkeypatch.setattr(ts, "CYCLE_RETRO_FILE", tmp_path / "retro" / "cycles.jsonl") + + # Setup: existing queue with 2 items (simulating deep triage cut) + existing = [ + {"issue": 1, "title": "Existing A", "ready": True, "score": 8}, + {"issue": 2, "title": "Existing B", "ready": True, "score": 7}, + ] + ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.QUEUE_FILE.write_text(json.dumps(existing)) + + # Simulate merge logic (extracted from run_triage) + newly_ready = [ + {"issue": 1, "title": "Existing A", "ready": True, "score": 8}, # duplicate + {"issue": 2, "title": "Existing B", "ready": True, "score": 7}, # duplicate + {"issue": 3, "title": "New C", "ready": True, "score": 9}, # new + ] + exclusions = [] + + existing_queue = json.loads(ts.QUEUE_FILE.read_text()) + existing_issues = {item["issue"] for item in existing_queue} + new_items = [ + s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions + ] + merged = existing_queue + new_items + + # Should preserve existing (2 items) + add new (1 item) = 3 items + assert len(merged) == 3 + assert merged[0]["issue"] == 1 + assert merged[1]["issue"] == 2 + assert merged[2]["issue"] == 3 + + +def test_excluded_issues_not_added(tmp_path, monkeypatch): + """Excluded issues are never added to the queue.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.EXCLUSIONS_FILE.write_text("[5, 10]") + + exclusions = ts.load_exclusions() + newly_ready = [ + {"issue": 5, "title": "Excluded A", "ready": True}, + {"issue": 6, "title": "New B", "ready": True}, + {"issue": 10, "title": "Excluded C", "ready": True}, + ] + + # Filter out excluded + filtered = [s for s in newly_ready if s["issue"] not in exclusions] + + assert len(filtered) == 1 + assert filtered[0]["issue"] == 6 + + +def test_excluded_issues_removed_from_scored(tmp_path, monkeypatch): + """Excluded issues are filtered out before any queue logic.""" + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + ts.EXCLUSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.EXCLUSIONS_FILE.write_text("[42]") + + exclusions = ts.load_exclusions() + scored = [ + {"issue": 41, "title": "Keep", "ready": True}, + {"issue": 42, "title": "Excluded", "ready": True}, + {"issue": 43, "title": "Keep Too", "ready": True}, + ] + + filtered = [s for s in scored if s["issue"] not in exclusions] + + assert len(filtered) == 2 + assert 42 not in [s["issue"] for s in filtered] + + +def test_empty_queue_merge_adds_all_new_items(tmp_path, monkeypatch): + """When queue is empty, all new ready items are added.""" + monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json") + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + + # No existing queue file + assert not ts.QUEUE_FILE.exists() + + newly_ready = [ + {"issue": 1, "title": "A", "ready": True}, + {"issue": 2, "title": "B", "ready": True}, + ] + exclusions = ts.load_exclusions() + + existing_queue = [] + if ts.QUEUE_FILE.exists(): + existing_queue = json.loads(ts.QUEUE_FILE.read_text()) + + existing_issues = {item["issue"] for item in existing_queue} + new_items = [ + s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions + ] + merged = existing_queue + new_items + + assert len(merged) == 2 + assert merged[0]["issue"] == 1 + assert merged[1]["issue"] == 2 + + +def test_queue_preserved_when_no_new_ready_items(tmp_path, monkeypatch): + """Existing queue is preserved even when no new ready items are found.""" + monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json") + monkeypatch.setattr(ts, "EXCLUSIONS_FILE", tmp_path / "exclusions.json") + + existing = [{"issue": 1, "title": "Only Item", "ready": True}] + ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + ts.QUEUE_FILE.write_text(json.dumps(existing)) + + newly_ready = [] # No new ready items + exclusions = ts.load_exclusions() + + existing_queue = json.loads(ts.QUEUE_FILE.read_text()) + existing_issues = {item["issue"] for item in existing_queue} + new_items = [ + s for s in newly_ready if s["issue"] not in existing_issues and s["issue"] not in exclusions + ] + merged = existing_queue + new_items + + assert len(merged) == 1 + assert merged[0]["issue"] == 1 -- 2.43.0