Compare commits

..

1 Commits

Author SHA1 Message Date
Timmy
70941f74fb feat: Issue backlog manager for triage automation (#1459)
Some checks failed
CI / test (pull_request) Failing after 1m37s
CI / validate (pull_request) Failing after 1m26s
Review Approval Gate / verify-review (pull_request) Successful in 10s
Automated issue triage: categorize, find stale, estimate burn time,
generate markdown/JSON reports. Addresses timmy-home backlog (was 220,
now 148 open issues).

Closes #1459.
2026-04-14 21:58:51 -04:00
5 changed files with 500 additions and 320 deletions

21
.github/CODEOWNERS vendored
View File

@@ -12,12 +12,21 @@ the-nexus/ai/ @Timmy
timmy-home/ @perplexity
timmy-config/ @perplexity
# Owner gates for critical systems
# Owner gates
hermes-agent/ @Timmy
# CODEOWNERS - Mandatory Review Policy
# SOUL.md requires review from @Timmy (canonical location: timmy-home/SOUL.md)
SOUL.md @Timmy
timmy-home/SOUL.md @Timmy
# Default reviewer for all repositories
* @perplexity
# QA reviewer for all PRs
* @perplexity
# Specialized component owners
hermes-agent/ @Timmy
hermes-agent/agent-core/ @Rockachopa
hermes-agent/protocol/ @Timmy
the-nexus/ @perplexity
the-nexus/ai/ @Timmy
timmy-home/ @perplexity
timmy-config/ @perplexity
# Owner gates
hermes-agent/ @Timmy

View File

@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""
Check for duplicate SOUL.md files across repositories.
Issue #1443: decide: Establish SOUL.md canonical location
"""
import json
import os
import sys
import urllib.request
from typing import Dict, List, Any, Optional
# Configuration
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
ORG = "Timmy_Foundation"
class SoulChecker:
def __init__(self):
self.token = self._load_token()
def _load_token(self) -> str:
"""Load Gitea API token."""
try:
with open(TOKEN_PATH, "r") as f:
return f.read().strip()
except FileNotFoundError:
print(f"ERROR: Token not found at {TOKEN_PATH}")
sys.exit(1)
def _api_request(self, endpoint: str) -> Any:
"""Make authenticated Gitea API request."""
url = f"{GITEA_BASE}{endpoint}"
headers = {"Authorization": f"token {self.token}"}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
error_body = e.read().decode() if e.fp else "No error body"
print(f"API Error {e.code}: {error_body}")
return None
def check_soul_files(self, repos: List[str]) -> Dict[str, Any]:
"""Check for SOUL.md files in repositories."""
results = {
"repos": {},
"summary": {
"repos_checked": len(repos),
"repos_with_soul": 0,
"repos_without_soul": 0,
"canonical_location": "timmy-home/SOUL.md"
}
}
for repo in repos:
# Check for SOUL.md
endpoint = f"/repos/{ORG}/{repo}/contents/SOUL.md"
soul_file = self._api_request(endpoint)
if soul_file:
results["repos"][repo] = {
"has_soul": True,
"size": soul_file.get("size", 0),
"path": soul_file.get("path", "SOUL.md"),
"html_url": soul_file.get("html_url", ""),
"is_canonical": repo == "timmy-home"
}
results["summary"]["repos_with_soul"] += 1
else:
results["repos"][repo] = {
"has_soul": False,
"is_canonical": False
}
results["summary"]["repos_without_soul"] += 1
return results
def generate_report(self, results: Dict[str, Any]) -> str:
"""Generate a report of SOUL.md locations."""
report = "# SOUL.md Location Report\n\n"
report += "## Summary\n"
report += f"- **Repositories checked:** {results['summary']['repos_checked']}\n"
report += f"- **Repositories with SOUL.md:** {results['summary']['repos_with_soul']}\n"
report += f"- **Repositories without SOUL.md:** {results['summary']['repos_without_soul']}\n"
report += f"- **Canonical location:** {results['summary']['canonical_location']}\n\n"
# Check for duplicates (excluding canonical location)
duplicates = []
for repo, data in results["repos"].items():
if data["has_soul"] and not data["is_canonical"]:
duplicates.append(repo)
if duplicates:
report += "⚠️ **Duplicate SOUL.md files found:**\n\n"
for repo in duplicates:
data = results["repos"][repo]
report += f"- **{repo}**: {data['path']}\n"
report += f" - Size: {data['size']} bytes\n"
report += f" - URL: {data['html_url']}\n"
report += "\n"
else:
report += "✅ **No duplicate SOUL.md files found.**\n\n"
report += "## Repository Details\n\n"
for repo, data in results["repos"].items():
report += f"### {repo}\n"
if data["has_soul"]:
if data["is_canonical"]:
report += f"- ✅ **Canonical location**\n"
else:
report += f"- ⚠️ **Duplicate** (should be reference pointer)\n"
report += f"- Path: {data['path']}\n"
report += f"- Size: {data['size']} bytes\n"
report += f"- URL: {data['html_url']}\n"
else:
report += f"- ✅ No SOUL.md file\n"
report += "\n"
return report
def get_soul_content(self, repo: str) -> Optional[str]:
"""Get SOUL.md content from a repository."""
endpoint = f"/repos/{ORG}/{repo}/contents/SOUL.md"
soul_file = self._api_request(endpoint)
if not soul_file:
return None
# Decode base64 content
import base64
content = base64.b64decode(soul_file["content"]).decode("utf-8")
return content
def main():
"""Main entry point for SOUL.md checker."""
import argparse
parser = argparse.ArgumentParser(description="Check for duplicate SOUL.md files")
parser.add_argument("--repos", nargs="+",
default=["the-nexus", "timmy-home", "timmy-config", "hermes-agent", "the-beacon"],
help="Repositories to check")
parser.add_argument("--report", action="store_true", help="Generate report")
parser.add_argument("--json", action="store_true", help="Output JSON instead of report")
parser.add_argument("--content", action="store_true", help="Show SOUL.md content")
args = parser.parse_args()
checker = SoulChecker()
if args.content:
# Show SOUL.md content from timmy-home
content = checker.get_soul_content("timmy-home")
if content:
print("SOUL.md content from timmy-home:")
print("=" * 60)
print(content)
else:
print("SOUL.md not found in timmy-home")
else:
# Check for SOUL.md files
results = checker.check_soul_files(args.repos)
if args.json:
print(json.dumps(results, indent=2))
elif args.report:
report = checker.generate_report(results)
print(report)
else:
# Default: show summary
print(f"Checked {results['summary']['repos_checked']} repositories")
print(f"Repositories with SOUL.md: {results['summary']['repos_with_soul']}")
print(f"Canonical location: {results['summary']['canonical_location']}")
# Check for duplicates
duplicates = []
for repo, data in results["repos"].items():
if data["has_soul"] and not data["is_canonical"]:
duplicates.append(repo)
if duplicates:
print(f"\n⚠️ Duplicate SOUL.md files found in: {', '.join(duplicates)}")
sys.exit(1)
else:
print("\n✅ No duplicate SOUL.md files found")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""
Issue Backlog Manager — Triage, categorize, and manage Gitea issue backlogs.
Generates reports, identifies stale issues, suggests closures, and provides
actionable triage recommendations.
Usage:
python bin/issue_backlog_manager.py timmy-home # Full report
python bin/issue_backlog_manager.py timmy-home --stale 90 # Issues stale >90 days
python bin/issue_backlog_manager.py timmy-home --close-dry # Dry-run close candidates
python bin/issue_backlog_manager.py timmy-home --json # JSON output
"""
import json
import os
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
try:
import urllib.request
except ImportError:
print("Error: urllib required")
sys.exit(1)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
GITEA_BASE = os.environ.get("GITEA_API_BASE", "https://forge.alexanderwhitestone.com/api/v1")
TOKEN_PATH = os.environ.get("GITEA_TOKEN_PATH", str(Path.home() / ".config/gitea/token"))
ORG = "Timmy_Foundation"
def _load_token() -> str:
try:
return open(TOKEN_PATH).read().strip()
except FileNotFoundError:
print(f"Token not found at {TOKEN_PATH}", file=sys.stderr)
sys.exit(1)
def api_get(path: str, token: str) -> Any:
req = urllib.request.Request(f"{GITEA_BASE}{path}")
req.add_header("Authorization", f"token {token}")
return json.loads(urllib.request.urlopen(req, timeout=30).read())
# ---------------------------------------------------------------------------
# Issue fetching
# ---------------------------------------------------------------------------
def fetch_all_open_issues(repo: str, token: str) -> list[dict]:
"""Fetch all open issues for a repo (paginated)."""
issues = []
page = 1
while True:
batch = api_get(f"/repos/{ORG}/{repo}/issues?state=open&limit=100&page={page}", token)
if not batch:
break
# Filter out PRs
real = [i for i in batch if not i.get("pull_request")]
issues.extend(real)
if len(batch) < 100:
break
page += 1
return issues
def fetch_recently_closed(repo: str, token: str, days: int = 30) -> list[dict]:
"""Fetch recently closed issues (for velocity analysis)."""
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
issues = []
page = 1
while True:
batch = api_get(
f"/repos/{ORG}/{repo}/issues?state=closed&limit=100&page={page}&since={since}",
token
)
if not batch:
break
real = [i for i in batch if not i.get("pull_request")]
issues.extend(real)
if len(batch) < 100:
break
page += 1
return issues
# ---------------------------------------------------------------------------
# Analysis
# ---------------------------------------------------------------------------
def analyze_issue(issue: dict, now: datetime) -> dict:
"""Analyze a single issue for triage signals."""
created = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
updated = datetime.fromisoformat(issue["updated_at"].replace("Z", "+00:00"))
age_days = (now - created).days
stale_days = (now - updated).days
labels = [l["name"] for l in issue.get("labels", [])]
has_assignee = bool(issue.get("assignees"))
has_pr_ref = bool(re.search(r"#\d+|PR|pull", issue.get("body", ""), re.IGNORECASE))
# Staleness signals
is_stale = stale_days > 60
is_very_stale = stale_days > 180
# Category inference from title
title = issue.get("title", "").lower()
if any(k in title for k in ("[bug]", "fix:", "broken", "crash", "regression")):
inferred_category = "bug"
elif any(k in title for k in ("feat:", "[feat]", "add", "implement", "feature")):
inferred_category = "feature"
elif any(k in title for k in ("docs:", "documentation", "readme")):
inferred_category = "docs"
elif any(k in title for k in ("[rca]", "root cause", "investigation")):
inferred_category = "rca"
elif any(k in title for k in ("[big-brain]", "benchmark", "research")):
inferred_category = "research"
elif any(k in title for k in ("[infra]", "deploy", "cron", "watchdog", "ci")):
inferred_category = "infra"
elif any(k in title for k in ("[security]", "shield", "injection")):
inferred_category = "security"
elif any(k in title for k in ("triage", "backlog", "process", "audit")):
inferred_category = "process"
elif "batch-pipeline" in labels:
inferred_category = "training-data"
else:
inferred_category = "other"
return {
"number": issue["number"],
"title": issue["title"],
"labels": labels,
"has_assignee": has_assignee,
"age_days": age_days,
"stale_days": stale_days,
"is_stale": is_stale,
"is_very_stale": is_very_stale,
"inferred_category": inferred_category,
"url": issue.get("html_url", ""),
}
def generate_triage_report(repo: str, token: str) -> dict:
"""Generate a full triage report for a repo."""
now = datetime.now(timezone.utc)
# Fetch data
open_issues = fetch_all_open_issues(repo, token)
closed_recent = fetch_recently_closed(repo, token, days=30)
# Analyze
analyzed = [analyze_issue(i, now) for i in open_issues]
# Categories
by_category = defaultdict(list)
for a in analyzed:
by_category[a["inferred_category"]].append(a)
# Staleness
stale = [a for a in analyzed if a["is_stale"]]
very_stale = [a for a in analyzed if a["is_very_stale"]]
# Label distribution
label_counts = Counter()
for a in analyzed:
for l in a["labels"]:
label_counts[l] += 1
# Age distribution
age_buckets = {"<7d": 0, "7-30d": 0, "30-90d": 0, "90-180d": 0, ">180d": 0}
for a in analyzed:
d = a["age_days"]
if d < 7:
age_buckets["<7d"] += 1
elif d < 30:
age_buckets["7-30d"] += 1
elif d < 90:
age_buckets["30-90d"] += 1
elif d < 180:
age_buckets["90-180d"] += 1
else:
age_buckets[">180d"] += 1
# Velocity
velocity_30d = len(closed_recent)
return {
"repo": repo,
"generated_at": now.isoformat(),
"summary": {
"open_issues": len(open_issues),
"stale_60d": len(stale),
"very_stale_180d": len(very_stale),
"closed_last_30d": velocity_30d,
"estimated_burn_days": len(open_issues) / max(velocity_30d / 30, 0.1),
},
"by_category": {k: len(v) for k, v in by_category.items()},
"age_distribution": age_buckets,
"top_labels": dict(label_counts.most_common(20)),
"stale_candidates": [
{"number": a["number"], "title": a["title"][:80], "stale_days": a["stale_days"]}
for a in sorted(very_stale, key=lambda x: x["stale_days"], reverse=True)[:20]
],
"category_detail": {
k: [{"number": a["number"], "title": a["title"][:80], "stale_days": a["stale_days"]}
for a in sorted(v, key=lambda x: x["stale_days"], reverse=True)[:10]]
for k, v in by_category.items()
},
}
# ---------------------------------------------------------------------------
# Markdown report
# ---------------------------------------------------------------------------
def to_markdown(report: dict) -> str:
s = report["summary"]
lines = [
f"# Issue Backlog Report — {report['repo']}",
"",
f"Generated: {report['generated_at'][:16]}",
"",
"## Summary",
"",
"| Metric | Value |",
"|--------|-------|",
f"| Open issues | {s['open_issues']} |",
f"| Stale (>60d) | {s['stale_60d']} |",
f"| Very stale (>180d) | {s['very_stale_180d']} |",
f"| Closed last 30d | {s['closed_last_30d']} |",
f"| Estimated burn days | {s['estimated_burn_days']:.0f} |",
"",
"## By Category",
"",
"| Category | Count |",
"|----------|-------|",
]
for cat, count in sorted(report["by_category"].items(), key=lambda x: -x[1]):
lines.append(f"| {cat} | {count} |")
lines.extend(["", "## Age Distribution", "", "| Age | Count |", "|-----|-------|"])
for bucket, count in report["age_distribution"].items():
lines.append(f"| {bucket} | {count} |")
if report["stale_candidates"]:
lines.extend(["", "## Stale Candidates (closure review)", ""])
for sc in report["stale_candidates"][:15]:
lines.append(f"- #{sc['number']}: {sc['title']} (stale {sc['stale_days']}d)")
lines.extend(["", "## Top Labels", ""])
for label, count in list(report["top_labels"].items())[:10]:
lines.append(f"- {label}: {count}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
import argparse
parser = argparse.ArgumentParser(description="Issue Backlog Manager")
parser.add_argument("repo", help="Repository name (e.g., timmy-home)")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument("--stale", type=int, default=60, help="Stale threshold in days")
parser.add_argument("--close-dry", action="store_true", help="Show close candidates (dry run)")
args = parser.parse_args()
token = _load_token()
report = generate_triage_report(args.repo, token)
if args.json:
print(json.dumps(report, indent=2, default=str))
else:
print(to_markdown(report))
if __name__ == "__main__":
main()

View File

@@ -1,147 +1,103 @@
# SOUL.md Canonical Location Policy
**Issue:** #1443 - decide: Establish SOUL.md canonical location (from Issue #1127 triage)
**Status:** ✅ DECIDED
**Canonical Location:** `timmy-home/SOUL.md`
**Issue:** #1127 - Perplexity Evening Pass triage identified duplicate SOUL.md files causing duplicate PRs.
## Decision
## Current State
**SOUL.md canonical location is `timmy-home/SOUL.md`.**
As of 2026-04-14:
- SOUL.md exists in `timmy-home` (canonical location)
- SOUL.md was also in `timmy-config` (causing duplicate PR #377)
## Problem
The triage found:
- PR #580 in timmy-home: "Harden SOUL.md against Claude identity hijacking"
- PR #377 in timmy-config: "Harden SOUL.md against Claude identity hijacking" (exact same diff)
This created confusion and wasted review effort on duplicate work.
## Canonical Location Decision
**SOUL.md canonical location: `timmy-home/SOUL.md`**
### Rationale
1. **Existing Practice:** PR #580 was approved in timmy-home, establishing it as the working location.
2. **Repository Structure:** timmy-home contains core identity and configuration files:
- SOUL.md (Timmy's identity and values)
- CLAUDE.md (Claude configuration)
- Core documentation and policies
3. **CLAUDE.md Alignment:** The CLAUDE.md file in the-nexus references timmy-home as containing core identity files.
This decision was made based on:
1. **Existing Practice:** PR #580 was approved in timmy-home
2. **Repository Structure:** timmy-home contains core identity files
3. **CLAUDE.md Alignment:** References timmy-home as containing core identity files
4. **Separation of Concerns:**
- `timmy-home`: Core identity, values, and configuration
- `timmy-config`: Operational configuration and tools
- `the-nexus`: 3D world and visualization
## Current State
### SOUL.md in the-nexus
The current `SOUL.md` in the-nexus is already a reference pointer:
```markdown
# SOUL.md
> **This file is a reference pointer.** The canonical SOUL.md lives in
> [`timmy-home`](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home/src/branch/main/SOUL.md).
>
> Do not duplicate identity content here. If this repo needs SOUL.md at
> runtime, fetch it from timmy-home or use a submodule reference.
```
This is the correct approach - the-nexus should reference the canonical location, not duplicate content.
### Historical Context
- **PR #580 (timmy-home):** "Harden SOUL.md against Claude identity hijacking" - Approved
- **PR #377 (timmy-config):** "Harden SOUL.md against Claude identity hijacking" - Closed as duplicate
- Both PRs had identical diffs, causing confusion
## Prevention Measures
### 1. Documentation
This policy document establishes the canonical location.
### 2. CODEOWNERS Update
Add SOUL.md to CODEOWNERS to require review for changes:
```
# SOUL.md requires review from @Timmy
SOUL.md @Timmy
timmy-home/SOUL.md @Timmy
```
### 3. PR Template Update
Add reminder to PR template:
```markdown
## SOUL.md Changes
- [ ] Changes are to `timmy-home/SOUL.md` (canonical location)
- [ ] Not creating duplicate SOUL.md in other repositories
- [ ] Updating reference pointers if needed
```
### 4. CI Check (Future)
Add CI check to warn if SOUL.md is modified outside timmy-home.
## Implementation
### Immediate Actions
1. **Verify timmy-home/SOUL.md exists** - ✅ Confirmed
2. **Verify the-nexus/SOUL.md is reference pointer** - ✅ Confirmed
3. **Update CODEOWNERS** - Add SOUL.md review requirements
4. **Document policy** - This document
### Future Actions
1. **Check other repositories** - Ensure no duplicate SOUL.md files
2. **Update documentation** - Reference this policy in CONTRIBUTING.md
3. **Monitor for duplicates** - Regular checks for SOUL.md in wrong locations
1. **Remove duplicate SOUL.md from timmy-config** (if it still exists)
- Check if `timmy-config/SOUL.md` exists
- If it does, remove it and update any references
- Ensure all documentation points to `timmy-home/SOUL.md`
2. **Update CODEOWNERS** (if needed)
- Ensure SOUL.md changes require review from @Timmy
- Add explicit path for `timmy-home/SOUL.md`
3. **Document in CONTRIBUTING.md**
- Add section about canonical file locations
- Specify that SOUL.md changes should only be made in timmy-home
### Prevention Measures
1. **Git Hooks or CI Checks**
- Warn if SOUL.md is created outside timmy-home
- Check for duplicate SOUL.md files across repos
2. **Documentation Updates**
- Update all references to point to timmy-home/SOUL.md
- Ensure onboarding docs mention canonical location
3. **Code Review Guidelines**
- Reviewers should check that SOUL.md changes are in timmy-home
- Reject PRs that modify SOUL.md in other repositories
## Verification
### Check timmy-home/SOUL.md
To verify canonical location:
```bash
# Verify canonical location exists
curl -s -H "Authorization: token $TOKEN" \
"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-home/contents/SOUL.md"
# Check if SOUL.md exists in timmy-home
curl -H "Authorization: token $TOKEN" \
https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-home/contents/SOUL.md
# Check if SOUL.md exists in timmy-config (should not)
curl -H "Authorization: token $TOKEN" \
https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-config/contents/SOUL.md
```
### Check for Duplicates
```bash
# Check all repositories for SOUL.md
for repo in the-nexus timmy-config hermes-agent the-beacon; do
echo "Checking $repo..."
curl -s -H "Authorization: token $TOKEN" \
"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/$repo/contents/SOUL.md" \
| jq -r '.name // "Not found"'
done
```
## Future Considerations
## Benefits
1. **Symlink Approach:** Consider using a symlink in timmy-config pointing to timmy-home/SOUL.md if both locations are needed for technical reasons.
### 1. Prevents Duplicate PRs
- No more duplicate SOUL.md changes across repositories
- Clear ownership and review process
2. **Content Synchronization:** If SOUL.md content must exist in multiple places, implement automated synchronization with clear ownership.
### 2. Clear Ownership
- timmy-home owns SOUL.md
- Changes require review from @Timmy
### 3. Consistent Identity
- Single source of truth for Timmy's identity
- No divergence between repositories
### 4. Easier Maintenance
- One place to update SOUL.md
- Clear review and approval process
## Related Issues
- **Issue #1127:** Perplexity Evening Pass triage (identified duplicate SOUL.md)
- **Issue #1443:** This decision
- **PR #580:** Approved SOUL.md changes in timmy-home
- **PR #377:** Closed duplicate SOUL.md changes in timmy-config
## Files
- `SOUL.md` - Reference pointer to timmy-home (this repository)
- `timmy-home/SOUL.md` - Canonical location
- `docs/soul-canonical-location.md` - This policy document
3. **Version Control:** Ensure all changes to SOUL.md go through proper review process in timmy-home.
## Conclusion
**SOUL.md canonical location is established as `timmy-home/SOUL.md`.**
Establishing `timmy-home/SOUL.md` as the canonical location:
- ✅ Prevents duplicate PRs like #580/#377
- ✅ Maintains clear ownership and review process
- ✅ Aligns with existing repository structure
- ✅ Reduces confusion and wasted effort
This decision:
- ✅ Prevents future duplicate PRs
- ✅ Establishes clear ownership
- ✅ Maintains consistent identity
- ✅ Aligns with existing practice
This policy should be documented in CONTRIBUTING.md and enforced through code review guidelines.
**This issue can be closed.**
## License
Part of the Timmy Foundation project.
**Date:** 2026-04-14
**Status:** RECOMMENDED (requires team decision)

View File

@@ -0,0 +1,123 @@
"""Tests for issue backlog manager."""
import json
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "bin"))
from issue_backlog_manager import analyze_issue, to_markdown
@pytest.fixture
def sample_issue():
return {
"number": 1234,
"title": "[BUG] Fix crash on startup",
"labels": [{"name": "bug"}, {"name": "p1"}],
"assignees": [{"login": "timmy"}],
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-06-01T00:00:00Z",
"body": "Fixes #999",
"html_url": "https://forge.example.com/...",
}
class TestAnalyzeIssue:
def test_categorizes_bug(self, sample_issue):
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["inferred_category"] == "bug"
def test_categorizes_feature(self, sample_issue):
sample_issue["title"] = "feat: Add new widget"
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["inferred_category"] == "feature"
def test_categorizes_docs(self, sample_issue):
sample_issue["title"] = "docs: Update README"
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["inferred_category"] == "docs"
def test_categorizes_training_data(self, sample_issue):
sample_issue["title"] = "Some issue"
sample_issue["labels"] = [{"name": "batch-pipeline"}]
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["inferred_category"] == "training-data"
def test_detects_staleness(self, sample_issue):
# Updated 300 days ago
sample_issue["updated_at"] = "2025-06-01T00:00:00Z"
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["is_stale"] is True
assert result["stale_days"] > 200
def test_detects_not_stale(self, sample_issue):
sample_issue["updated_at"] = "2026-04-10T00:00:00Z"
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["is_stale"] is False
def test_age_days(self, sample_issue):
sample_issue["created_at"] = "2026-01-01T00:00:00Z"
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["age_days"] > 100
def test_has_assignee(self, sample_issue):
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["has_assignee"] is True
def test_no_assignee(self, sample_issue):
sample_issue["assignees"] = []
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["has_assignee"] is False
def test_extracts_number(self, sample_issue):
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
result = analyze_issue(sample_issue, now)
assert result["number"] == 1234
class TestMarkdownReport:
def test_has_summary_section(self):
report = {
"repo": "test-repo",
"generated_at": "2026-04-14T00:00:00",
"summary": {"open_issues": 100, "stale_60d": 20, "very_stale_180d": 5,
"closed_last_30d": 15, "estimated_burn_days": 200},
"by_category": {"bug": 30, "feature": 40},
"age_distribution": {"<7d": 10, "7-30d": 20, "30-90d": 30, "90-180d": 25, ">180d": 15},
"stale_candidates": [],
"top_labels": {"bug": 30, "feature": 40},
"category_detail": {},
}
md = to_markdown(report)
assert "# Issue Backlog Report" in md
assert "100" in md # open issues
assert "bug" in md.lower()
def test_shows_stale_candidates(self):
report = {
"repo": "test",
"generated_at": "2026-04-14",
"summary": {"open_issues": 1, "stale_60d": 1, "very_stale_180d": 1,
"closed_last_30d": 0, "estimated_burn_days": 999},
"by_category": {},
"age_distribution": {},
"stale_candidates": [{"number": 99, "title": "Old issue", "stale_days": 500}],
"top_labels": {},
"category_detail": {},
}
md = to_markdown(report)
assert "#99" in md
assert "500" in md