Implements scripts/issue_backlog_triage.py — automated issue backlog analysis and triage for Gitea repos, addressing the 559-issue backlog audit finding. Features: - Paginated fetch of all open issues across repos - Keyword-based categorization (adversary, bug, security, training_data, …) - Duplicate detection via issue reference (#N) sharing - Stale identification (>14d with no activity) - Optional dry-run close of stale issues (--close-stale) - Optional priority label application (P0–P3) with auto-creation (--apply-priority) - Markdown and JSON report outputs Unit tests added in tests/test_issue_backlog_triage.py (27 tests, all passing). Enables systematic sweep of timmy-home, timmy-config, the-nexus, and hermes-agent backlogs per issue #478 acceptance criteria. Closes #478
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for issue_backlog_triage.py — Issue #478."""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
|
from datetime import datetime, timezone, timedelta
|
|
from issue_backlog_triage import (
|
|
categorize_issue,
|
|
extract_refs,
|
|
find_duplicates,
|
|
is_stale,
|
|
STALE_DAYS,
|
|
)
|
|
|
|
|
|
class TestCategorize:
|
|
def test_training_data(self):
|
|
issue = {"title": "feat: 500 emotional weather pairs (#603)"}
|
|
assert categorize_issue(issue) == "training_data"
|
|
|
|
def test_scene_description(self):
|
|
issue = {"title": "Scene Descriptions: Jazz — 100 Lyrics→Visual"}
|
|
assert categorize_issue(issue) == "training_data"
|
|
|
|
def test_adversary(self):
|
|
issue = {"title": "Adversary: Jailbreak Generator — 1K Prompts"}
|
|
assert categorize_issue(issue) == "adversary"
|
|
|
|
def test_bug(self):
|
|
issue = {"title": "fix: broken import in cli.py"}
|
|
assert categorize_issue(issue) == "bug"
|
|
|
|
def test_feature(self):
|
|
issue = {"title": "feat: add token budget tracker"}
|
|
assert categorize_issue(issue) == "feature"
|
|
|
|
def test_docs(self):
|
|
issue = {"title": "docs: update README with new config format"}
|
|
assert categorize_issue(issue) == "docs"
|
|
|
|
def test_ops(self):
|
|
issue = {"title": "ops: deploy config to VPS"}
|
|
assert categorize_issue(issue) == "ops"
|
|
|
|
def test_security(self):
|
|
issue = {"title": "security: fix XSS in gallery panel"}
|
|
assert categorize_issue(issue) == "security"
|
|
|
|
def test_governance(self):
|
|
issue = {"title": "[AUDIT] Triage the backlog"}
|
|
assert categorize_issue(issue) == "governance"
|
|
|
|
def test_research(self):
|
|
issue = {"title": "research: investigate model drift"}
|
|
assert categorize_issue(issue) == "research"
|
|
|
|
def test_epic(self):
|
|
issue = {"title": "[EPIC] Contraction sweep across all repos"}
|
|
assert categorize_issue(issue) == "epic"
|
|
|
|
def test_other(self):
|
|
issue = {"title": "chore: cleanup whitespace"}
|
|
assert categorize_issue(issue) == "other"
|
|
|
|
def test_case_insensitive(self):
|
|
issue = {"title": "FIX: resolve import error"}
|
|
assert categorize_issue(issue) == "bug"
|
|
|
|
def test_empty_title(self):
|
|
issue = {"title": ""}
|
|
assert categorize_issue(issue) == "other"
|
|
|
|
def test_none_title(self):
|
|
issue = {}
|
|
assert categorize_issue(issue) == "other"
|
|
|
|
|
|
class TestExtractRefs:
|
|
def test_single_ref(self):
|
|
issue = {"title": "Fix #123", "body": "Closes #123"}
|
|
assert extract_refs(issue) == [123]
|
|
|
|
def test_multiple_refs(self):
|
|
issue = {"title": "Fix #123", "body": "Related to #456 and #789"}
|
|
assert extract_refs(issue) == [123, 456, 789]
|
|
|
|
def test_deduplication(self):
|
|
issue = {"title": "#100", "body": "Fixes #100"}
|
|
assert extract_refs(issue) == [100]
|
|
|
|
def test_no_refs(self):
|
|
issue = {"title": "No issue here", "body": "Just an issue"}
|
|
assert extract_refs(issue) == []
|
|
|
|
def test_empty_body(self):
|
|
issue = {"title": "Fix #42", "body": None}
|
|
assert extract_refs(issue) == [42]
|
|
|
|
def test_numeric_like_text_not_refs(self):
|
|
issue = {"title": "Version 2.0 release", "body": "See build #1234"}
|
|
assert extract_refs(issue) == [1234]
|
|
|
|
|
|
class TestFindDuplicates:
|
|
def test_no_duplicates(self):
|
|
issues = [{"number": 1, "title": "Fix #10", "body": ""},
|
|
{"number": 2, "title": "Fix #11", "body": ""}]
|
|
assert find_duplicates(issues) == {}
|
|
|
|
def test_duplicates_found(self):
|
|
issues = [{"number": 1, "title": "Fix #10", "body": ""},
|
|
{"number": 2, "title": "Also fix #10", "body": ""}]
|
|
dupes = find_duplicates(issues)
|
|
assert 10 in dupes
|
|
assert dupes[10] == [1, 2]
|
|
|
|
def test_triple_duplicate(self):
|
|
issues = [{"number": 1, "title": "#42", "body": ""},
|
|
{"number": 2, "title": "#42", "body": ""},
|
|
{"number": 3, "title": "#42", "body": ""}]
|
|
dupes = find_duplicates(issues)
|
|
assert len(dupes[42]) == 3
|
|
|
|
def test_partial_overlap(self):
|
|
issues = [{"number": 1, "title": "#10 #20", "body": ""},
|
|
{"number": 2, "title": "#10", "body": ""}]
|
|
dupes = find_duplicates(issues)
|
|
assert 10 in dupes
|
|
assert 20 not in dupes
|
|
|
|
|
|
class TestIsStale:
|
|
def test_fresh_issue(self):
|
|
now = datetime.now(timezone.utc)
|
|
issue = {
|
|
"number": 1,
|
|
"title": "Fresh",
|
|
"updated_at": now.isoformat(),
|
|
"created_at": now.isoformat(),
|
|
}
|
|
assert not is_stale(issue, now - timedelta(days=STALE_DAYS))
|
|
|
|
def test_old_issue(self):
|
|
old = datetime.now(timezone.utc) - timedelta(days=STALE_DAYS + 1)
|
|
issue = {
|
|
"number": 2,
|
|
"title": "Old",
|
|
"updated_at": old.isoformat(),
|
|
"created_at": old.isoformat(),
|
|
}
|
|
assert is_stale(issue, datetime.now(timezone.utc) - timedelta(days=STALE_DAYS))
|