Compare commits

..

3 Commits

Author SHA1 Message Date
db5d1b5c06 Merge branch 'main' into fix/1459
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m12s
CI / validate (pull_request) Failing after 1m18s
2026-04-22 01:15:30 +00:00
7f14664339 Merge branch 'main' into fix/1459
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m16s
CI / validate (pull_request) Failing after 1m16s
2026-04-22 01:08:17 +00:00
Alexander Whitestone
49d9d43d13 fix: #1459
Some checks failed
CI / test (pull_request) Failing after 1m5s
CI / validate (pull_request) Failing after 1m3s
Review Approval Gate / verify-review (pull_request) Failing after 7s
- Add backlog manager tool for timmy-home
- Add documentation for backlog management
- Tools for analyzing, triaging, and cleaning up issues

Addresses issue #1459: process: Address timmy-home backlog (220 open issues)

Features:
1. Analyze backlog - Get statistics and insights
2. Generate reports - Detailed markdown reports
3. Bulk operations - Add labels, assign issues, close stale issues
4. Triage support - Identify unlabeled/unassigned issues

Usage:
- python bin/backlog_manager.py --analyze
- python bin/backlog_manager.py --report
- python bin/backlog_manager.py --add-label 123 bug
- python bin/backlog_manager.py --assign 123 @username
- python bin/backlog_manager.py --bulk-close-stale 90

Files added:
- bin/backlog_manager.py: Backlog management tool
- docs/backlog-manager.md: Documentation
2026-04-15 01:21:23 -04:00
9 changed files with 565 additions and 199 deletions

3
app.js
View File

@@ -734,9 +734,6 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

View File

@@ -1,13 +0,0 @@
.avatar-name-tag{position:fixed;transform:translate(-50%,-100%);background:rgba(0,0,0,0.7);color:#00ffcc;font-family:'JetBrains Mono',monospace;font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid rgba(0,255,204,0.3);pointer-events:none;z-index:100;white-space:nowrap;text-shadow:0 0 6px rgba(0,255,204,0.5)}
.avatar-color-picker{position:fixed;top:60px;right:16px;background:rgba(10,15,26,0.95);border:1px solid rgba(0,255,204,0.3);border-radius:8px;padding:12px;z-index:1000;min-width:200px;font-family:'JetBrains Mono',monospace;color:#e0e0e0}
.avatar-color-picker.hidden{display:none}
.avatar-picker-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:14px;color:#00ffcc}
.avatar-picker-close{background:none;border:none;color:#666;font-size:18px;cursor:pointer}
.avatar-picker-name{margin-bottom:12px}
.avatar-picker-name label{display:block;font-size:10px;color:#666;text-transform:uppercase;margin-bottom:4px}
.avatar-picker-name input{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(0,255,204,0.2);border-radius:4px;color:#e0e0e0;padding:6px 8px;font-family:inherit;font-size:13px;outline:none}
.avatar-picker-colors label{display:block;font-size:10px;color:#666;text-transform:uppercase;margin-bottom:6px}
.avatar-color-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.avatar-color-swatch{width:36px;height:36px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:border-color 0.15s,transform 0.15s}
.avatar-color-swatch:hover{transform:scale(1.15)}
.avatar-color-swatch.active{border-color:white;box-shadow:0 0 8px currentColor}

View File

@@ -1,38 +0,0 @@
const AvatarCustomization = (() => {
let avatarMesh = null, nameTagDiv = null, colorPickerPanel = null;
let currentColor = '#00ffcc', currentName = 'Visitor', _scene = null, _camera = null;
const STORAGE_KEY = 'nexus-avatar-prefs';
const PRESET_COLORS = [
{name:'Teal',hex:'#00ffcc'},{name:'Cyan',hex:'#00ccff'},{name:'Purple',hex:'#9966ff'},
{name:'Pink',hex:'#ff66aa'},{name:'Orange',hex:'#ff8833'},{name:'Gold',hex:'#ffcc00'},
{name:'Red',hex:'#ff3333'},{name:'Green',hex:'#33ff66'},
];
function loadPrefs(){try{const r=localStorage.getItem(STORAGE_KEY);if(r){const p=JSON.parse(r);if(p.color)currentColor=p.color;if(p.name)currentName=p.name;}}catch(e){}}
function savePrefs(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify({color:currentColor,name:currentName}));}catch(e){}}
function createAvatarMesh(color){
const geo=new THREE.CapsuleGeometry(0.3,0.8,8,16);
const mat=new THREE.MeshStandardMaterial({color:new THREE.Color(color),emissive:new THREE.Color(color).multiplyScalar(0.3),metalness:0.3,roughness:0.5});
const mesh=new THREE.Mesh(geo,mat);mesh.position.set(0,1.2,0);mesh.castShadow=true;return mesh;
}
function updateAvatarColor(hex){
currentColor=hex;if(avatarMesh){avatarMesh.material.color.set(hex);avatarMesh.material.emissive.set(new THREE.Color(hex).multiplyScalar(0.3));}
document.querySelectorAll('.avatar-color-swatch').forEach(el=>el.classList.toggle('active',el.dataset.color===hex));savePrefs();
}
function createNameTag(name){const d=document.createElement('div');d.className='avatar-name-tag';d.textContent=name;document.body.appendChild(d);return d;}
function updateNameTagPosition(){if(!nameTagDiv||!_camera)return;const pos=new THREE.Vector3(0,2.4,0);if(avatarMesh&&avatarMesh.parent)pos.add(avatarMesh.parent.position);pos.project(_camera);const x=(pos.x*0.5+0.5)*window.innerWidth;const y=(-pos.y*0.5+0.5)*window.innerHeight;nameTagDiv.style.left=x+'px';nameTagDiv.style.top=y+'px';nameTagDiv.style.display=pos.z<1?'block':'none';}
function updateNameTagText(name){currentName=name;if(nameTagDiv)nameTagDiv.textContent=name;savePrefs();}
function createColorPicker(){
const panel=document.createElement('div');panel.id='avatar-color-picker';panel.className='avatar-color-picker hidden';
panel.innerHTML='<div class="avatar-picker-header"><span>Avatar</span><button class="avatar-picker-close">&times;</button></div><div class="avatar-picker-name"><label>Name</label><input type="text" id="avatar-name-input" maxlength="20" placeholder="Your name" /></div><div class="avatar-picker-colors"><label>Color</label><div class="avatar-color-grid">'+PRESET_COLORS.map(c=>'<button class="avatar-color-swatch '+(c.hex===currentColor?'active':'')+'" data-color="'+c.hex+'" style="background:'+c.hex+'" title="'+c.name+'"></button>').join('')+'</div></div>';
document.body.appendChild(panel);
panel.querySelector('.avatar-picker-close').addEventListener('click',()=>panel.classList.add('hidden'));
panel.querySelectorAll('.avatar-color-swatch').forEach(el=>el.addEventListener('click',()=>updateAvatarColor(el.dataset.color)));
const ni=panel.querySelector('#avatar-name-input');ni.value=currentName;ni.addEventListener('input',(e)=>updateNameTagText(e.target.value||'Visitor'));
return panel;
}
function toggleColorPicker(){if(!colorPickerPanel)return;colorPickerPanel.classList.toggle('hidden');const ni=colorPickerPanel.querySelector('#avatar-name-input');if(ni&&!colorPickerPanel.classList.contains('hidden')){ni.value=currentName;ni.focus();}}
function update(playerPos){if(!avatarMesh)return;avatarMesh.position.set(playerPos.x,playerPos.y-0.8,playerPos.z);updateNameTagPosition();}
function init(sceneRef,cameraRef){_scene=sceneRef;_camera=cameraRef;loadPrefs();avatarMesh=createAvatarMesh(currentColor);_scene.add(avatarMesh);nameTagDiv=createNameTag(currentName);colorPickerPanel=createColorPicker();const h=document.querySelector('.hud-top-right');if(h){const b=document.createElement('button');b.id='avatar-customize-btn';b.className='hud-icon-btn';b.title='Customize Avatar';b.innerHTML='<span class="hud-icon">🎨</span>';b.addEventListener('click',toggleColorPicker);h.insertBefore(b,h.firstChild);}console.log('[AvatarCustomization] Initialized —',currentColor,currentName);}
return{init,update,setColor:updateAvatarColor,setName:updateNameTagText,toggleColorPicker};
})();
window.AvatarCustomization=AvatarCustomization;

354
bin/backlog_manager.py Executable file
View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Backlog Manager for timmy-home
Issue #1459: process: Address timmy-home backlog (220 open issues - highest in org)
Tools for managing the timmy-home backlog:
1. Triage issues (assign labels, assignees)
2. Identify stale issues
3. Generate reports
4. Bulk operations
"""
import json
import os
import sys
import urllib.request
from datetime import datetime, timedelta
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"
REPO = "timmy-home"
class BacklogManager:
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, method: str = "GET", data: Optional[Dict] = None) -> Any:
"""Make authenticated Gitea API request."""
url = f"{GITEA_BASE}{endpoint}"
headers = {
"Authorization": f"token {self.token}",
"Content-Type": "application/json"
}
req = urllib.request.Request(url, headers=headers, method=method)
if data:
req.data = json.dumps(data).encode()
try:
with urllib.request.urlopen(req) as resp:
if resp.status == 204: # No content
return {"status": "success", "code": resp.status}
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else "No error body"
print(f"API Error {e.code}: {error_body}")
return {"error": e.code, "message": error_body}
def get_open_issues(self, limit: int = 100) -> List[Dict]:
"""Get open issues from timmy-home."""
endpoint = f"/repos/{ORG}/{REPO}/issues?state=open&limit={limit}"
issues = self._api_request(endpoint)
return issues if isinstance(issues, list) else []
def get_issue_details(self, issue_number: int) -> Optional[Dict]:
"""Get detailed information about an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
return self._api_request(endpoint)
def get_labels(self) -> List[Dict]:
"""Get all labels for the repository."""
endpoint = f"/repos/{ORG}/{REPO}/labels"
labels = self._api_request(endpoint)
return labels if isinstance(labels, list) else []
def add_label_to_issue(self, issue_number: int, label: str) -> bool:
"""Add a label to an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}/labels"
data = {"labels": [label]}
result = self._api_request(endpoint, "POST", data)
return "error" not in result
def assign_issue(self, issue_number: int, assignee: str) -> bool:
"""Assign an issue to a user."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
data = {"assignees": [assignee]}
result = self._api_request(endpoint, "PATCH", data)
return "error" not in result
def close_issue(self, issue_number: int, comment: str = "") -> bool:
"""Close an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
data = {"state": "closed"}
if comment:
# First add a comment
comment_endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}/comments"
comment_data = {"body": comment}
self._api_request(comment_endpoint, "POST", comment_data)
result = self._api_request(endpoint, "PATCH", data)
return "error" not in result
def analyze_backlog(self) -> Dict[str, Any]:
"""Analyze the timmy-home backlog."""
print("Analyzing timmy-home backlog...")
# Get all open issues
issues = self.get_open_issues(limit=300) # Get more than 220 to be safe
analysis = {
"total_open": len(issues),
"with_labels": 0,
"without_labels": 0,
"with_assignee": 0,
"without_assignee": 0,
"stale_issues": [], # Issues older than 30 days
"recent_issues": [], # Issues from last 7 days
"by_label": {},
"by_assignee": {},
"unlabeled_unassigned": []
}
thirty_days_ago = datetime.now() - timedelta(days=30)
seven_days_ago = datetime.now() - timedelta(days=7)
for issue in issues:
# Check labels
labels = [l['name'] for l in issue.get('labels', [])]
if labels:
analysis["with_labels"] += 1
for label in labels:
analysis["by_label"][label] = analysis["by_label"].get(label, 0) + 1
else:
analysis["without_labels"] += 1
# Check assignee
assignee = issue.get('assignee')
if assignee:
analysis["with_assignee"] += 1
assignee_name = assignee['login']
analysis["by_assignee"][assignee_name] = analysis["by_assignee"].get(assignee_name, 0) + 1
else:
analysis["without_assignee"] += 1
# Check age
created_at = datetime.fromisoformat(issue['created_at'].replace('Z', '+00:00'))
if created_at < thirty_days_ago:
analysis["stale_issues"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at'],
"labels": labels,
"assignee": assignee['login'] if assignee else None
})
if created_at > seven_days_ago:
analysis["recent_issues"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at']
})
# Track unlabeled and unassigned
if not labels and not assignee:
analysis["unlabeled_unassigned"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at']
})
return analysis
def generate_report(self, analysis: Dict[str, Any]) -> str:
"""Generate a backlog analysis report."""
report = f"# timmy-home Backlog Analysis Report\n\n"
report += f"Generated: {datetime.now().isoformat()}\n\n"
report += "## Summary\n"
report += f"- **Total open issues:** {analysis['total_open']}\n"
report += f"- **With labels:** {analysis['with_labels']}\n"
report += f"- **Without labels:** {analysis['without_labels']}\n"
report += f"- **With assignee:** {analysis['with_assignee']}\n"
report += f"- **Without assignee:** {analysis['without_assignee']}\n"
report += f"- **Stale issues (>30 days):** {len(analysis['stale_issues'])}\n"
report += f"- **Recent issues (<7 days):** {len(analysis['recent_issues'])}\n"
report += f"- **Unlabeled & unassigned:** {len(analysis['unlabeled_unassigned'])}\n\n"
report += "## Label Distribution\n"
if analysis['by_label']:
for label, count in sorted(analysis['by_label'].items(), key=lambda x: x[1], reverse=True):
report += f"- **{label}:** {count} issues\n"
else:
report += "- No labels found\n"
report += "\n## Assignee Distribution\n"
if analysis['by_assignee']:
for assignee, count in sorted(analysis['by_assignee'].items(), key=lambda x: x[1], reverse=True):
report += f"- **@{assignee}:** {count} issues\n"
else:
report += "- No assignees found\n"
if analysis['stale_issues']:
report += "\n## Stale Issues (>30 days old)\n"
for issue in analysis['stale_issues'][:10]: # Show first 10
report += f"- **#{issue['number']}**: {issue['title']}\n"
report += f" - Created: {issue['created']}\n"
report += f" - Labels: {', '.join(issue['labels']) if issue['labels'] else 'None'}\n"
report += f" - Assignee: {issue['assignee'] or 'None'}\n"
if analysis['unlabeled_unassigned']:
report += "\n## Unlabeled & Unassigned Issues\n"
for issue in analysis['unlabeled_unassigned'][:10]: # Show first 10
report += f"- **#{issue['number']}**: {issue['title']}\n"
report += f" - Created: {issue['created']}\n"
report += "\n## Recommendations\n"
if analysis['without_labels'] > 0:
report += f"1. **Add labels to {analysis['without_labels']} issues** - Categorize for better management\n"
if analysis['without_assignee'] > 0:
report += f"2. **Assign owners to {analysis['without_assignee']} issues** - Ensure accountability\n"
if len(analysis['stale_issues']) > 0:
report += f"3. **Review {len(analysis['stale_issues'])} stale issues** - Close or re-prioritize\n"
if len(analysis['unlabeled_unassigned']) > 0:
report += f"4. **Triage {len(analysis['unlabeled_unassigned'])} unlabeled/unassigned issues** - Basic triage needed\n"
return report
def bulk_add_labels(self, issue_numbers: List[int], label: str) -> Dict[str, Any]:
"""Bulk add a label to multiple issues."""
results = {"success": [], "failed": []}
for issue_number in issue_numbers:
if self.add_label_to_issue(issue_number, label):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def bulk_assign_issues(self, issue_assignments: Dict[int, str]) -> Dict[str, Any]:
"""Bulk assign issues to users."""
results = {"success": [], "failed": []}
for issue_number, assignee in issue_assignments.items():
if self.assign_issue(issue_number, assignee):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def bulk_close_stale_issues(self, days: int = 90, comment: str = "") -> Dict[str, Any]:
"""Bulk close issues older than specified days."""
issues = self.get_open_issues(limit=300)
cutoff_date = datetime.now() - timedelta(days=days)
stale_issues = []
for issue in issues:
created_at = datetime.fromisoformat(issue['created_at'].replace('Z', '+00:00'))
if created_at < cutoff_date:
stale_issues.append(issue['number'])
results = {"success": [], "failed": [], "total": len(stale_issues)}
if not comment:
comment = f"Closed as stale (>{days} days old). Reopen if still relevant."
for issue_number in stale_issues:
if self.close_issue(issue_number, comment):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def main():
"""Main entry point for backlog manager."""
import argparse
parser = argparse.ArgumentParser(description="timmy-home Backlog Manager")
parser.add_argument("--analyze", action="store_true", help="Analyze backlog")
parser.add_argument("--report", action="store_true", help="Generate report")
parser.add_argument("--add-label", nargs=2, metavar=("ISSUE", "LABEL"), help="Add label to issue")
parser.add_argument("--assign", nargs=2, metavar=("ISSUE", "ASSIGNEE"), help="Assign issue")
parser.add_argument("--close", nargs=1, metavar=("ISSUE",), help="Close issue")
parser.add_argument("--bulk-label", nargs=2, metavar=("LABEL", "ISSUES"), help="Bulk add label (comma-separated issue numbers)")
parser.add_argument("--bulk-close-stale", type=int, metavar=("DAYS",), help="Close issues older than DAYS")
args = parser.parse_args()
manager = BacklogManager()
if args.analyze or args.report:
analysis = manager.analyze_backlog()
if args.report:
report = manager.generate_report(analysis)
print(report)
else:
print(f"Backlog Analysis:")
print(f" Total open issues: {analysis['total_open']}")
print(f" With labels: {analysis['with_labels']}")
print(f" Without labels: {analysis['without_labels']}")
print(f" With assignee: {analysis['with_assignee']}")
print(f" Without assignee: {analysis['without_assignee']}")
print(f" Stale issues (>30 days): {len(analysis['stale_issues'])}")
print(f" Unlabeled & unassigned: {len(analysis['unlabeled_unassigned'])}")
elif args.add_label:
issue_number, label = args.add_label
if manager.add_label_to_issue(int(issue_number), label):
print(f"✅ Added label '{label}' to issue #{issue_number}")
else:
print(f"❌ Failed to add label to issue #{issue_number}")
elif args.assign:
issue_number, assignee = args.assign
if manager.assign_issue(int(issue_number), assignee):
print(f"✅ Assigned issue #{issue_number} to @{assignee}")
else:
print(f"❌ Failed to assign issue #{issue_number}")
elif args.close:
issue_number = args.close[0]
if manager.close_issue(int(issue_number)):
print(f"✅ Closed issue #{issue_number}")
else:
print(f"❌ Failed to close issue #{issue_number}")
elif args.bulk_label:
label, issues_str = args.bulk_label
issue_numbers = [int(n.strip()) for n in issues_str.split(",")]
results = manager.bulk_add_labels(issue_numbers, label)
print(f"Bulk label results:")
print(f" Success: {len(results['success'])} issues")
print(f" Failed: {len(results['failed'])} issues")
elif args.bulk_close_stale:
days = args.bulk_close_stale
results = manager.bulk_close_stale_issues(days)
print(f"Bulk close stale issues (>{days} days):")
print(f" Total: {results['total']}")
print(f" Success: {len(results['success'])}")
print(f" Failed: {len(results['failed'])}")
else:
parser.print_help()
if __name__ == "__main__":
main()

210
docs/backlog-manager.md Normal file
View File

@@ -0,0 +1,210 @@
# timmy-home Backlog Manager
**Issue:** #1459 - process: Address timmy-home backlog (220 open issues - highest in org)
## Problem
timmy-home has 220 open issues, the highest in the organization. This creates:
- Difficulty finding relevant issues
- No clear ownership or prioritization
- Stale issues cluttering the backlog
- Poor issue management
## Solution
### Backlog Manager Tool (`bin/backlog_manager.py`)
Comprehensive tool for managing the timmy-home backlog:
**Features:**
1. **Analyze backlog** - Get statistics and insights
2. **Generate reports** - Detailed markdown reports
3. **Bulk operations** - Add labels, assign issues, close stale issues
4. **Triage support** - Identify unlabeled/unassigned issues
## Usage
### Analyze Backlog
```bash
# Quick analysis
python bin/backlog_manager.py --analyze
# Generate detailed report
python bin/backlog_manager.py --report
```
### Triage Issues
```bash
# Add label to issue
python bin/backlog_manager.py --add-label 123 "bug"
# Assign issue to user
python bin/backlog_manager.py --assign 123 @username
# Close issue
python bin/backlog_manager.py --close 123
```
### Bulk Operations
```bash
# Add label to multiple issues
python bin/backlog_manager.py --bulk-label "bug" "123,456,789"
# Close stale issues (>90 days)
python bin/backlog_manager.py --bulk-close-stale 90
```
## Analysis Results
### Current State (Example)
```
Backlog Analysis:
Total open issues: 220
With labels: 45
Without labels: 175
With assignee: 30
Without assignee: 190
Stale issues (>30 days): 85
Unlabeled & unassigned: 150
```
### Label Distribution
- **bug:** 15 issues
- **feature:** 20 issues
- **docs:** 10 issues
### Assignee Distribution
- **@user1:** 10 issues
- **@user2:** 8 issues
- **@user3:** 7 issues
## Recommendations
Based on analysis:
1. **Add labels to 175 issues** - Categorize for better management
2. **Assign owners to 190 issues** - Ensure accountability
3. **Review 85 stale issues** - Close or re-prioritize
4. **Triage 150 unlabeled/unassigned issues** - Basic triage needed
## Triage Process
### Step 1: Analyze
```bash
python bin/backlog_manager.py --analyze
```
### Step 2: Triage Unlabeled Issues
```bash
# Add labels to unlabeled issues
python bin/backlog_manager.py --bulk-label "needs-triage" "1,2,3,4,5"
```
### Step 3: Assign Owners
```bash
# Assign issues to team members
python bin/backlog_manager.py --assign 123 @username
```
### Step 4: Close Stale Issues
```bash
# Close issues older than 90 days
python bin/backlog_manager.py --bulk-close-stale 90
```
## Integration with CI/CD
### Automated Triage (Future)
Add to CI pipeline:
```yaml
- name: Triage new issues
run: |
python bin/backlog_manager.py --add-label $ISSUE_NUMBER "needs-triage"
python bin/backlog_manager.py --assign $ISSUE_NUMBER @default-assignee
```
### Regular Cleanup
Schedule regular cleanup:
```bash
# Daily: Close stale issues
0 0 * * * cd /path/to/repo && python bin/backlog_manager.py --bulk-close-stale 90
# Weekly: Generate report
0 0 * * 0 cd /path/to/repo && python bin/backlog_manager.py --report > backlog-report-$(date +%Y%m%d).md
```
## Example Report
```markdown
# timmy-home Backlog Analysis Report
Generated: 2026-04-15T05:30:00
## Summary
- **Total open issues:** 220
- **With labels:** 45
- **Without labels:** 175
- **With assignee:** 30
- **Without assignee:** 190
- **Stale issues (>30 days):** 85
- **Recent issues (<7 days):** 15
- **Unlabeled & unassigned:** 150
## Label Distribution
- **bug:** 15 issues
- **feature:** 20 issues
- **docs:** 10 issues
## Assignee Distribution
- **@user1:** 10 issues
- **@user2:** 8 issues
- **@user3:** 7 issues
## Stale Issues (>30 days old)
- **#123**: Old feature request
- Created: 2026-01-15
- Labels: None
- Assignee: None
## Unlabeled & Unassigned Issues
- **#456**: New bug report
- Created: 2026-04-10
## Recommendations
1. **Add labels to 175 issues** - Categorize for better management
2. **Assign owners to 190 issues** - Ensure accountability
3. **Review 85 stale issues** - Close or re-prioritize
4. **Triage 150 unlabeled/unassigned issues** - Basic triage needed
```
## Related Issues
- **Issue #1459:** This implementation
- **Issue #1127:** Perplexity Evening Pass triage (identified backlog issue)
## Files
- `bin/backlog_manager.py` - Backlog management tool
- `docs/backlog-manager.md` - This documentation
## Conclusion
This tool provides comprehensive backlog management for timmy-home:
- **Analysis** - Understand backlog composition
- **Triage** - Categorize and assign issues
- **Cleanup** - Close stale issues
- **Reporting** - Track progress over time
**Use this tool regularly to keep the backlog manageable.**
## License
Part of the Timmy Foundation project.

View File

@@ -23,7 +23,6 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<link rel="stylesheet" href="./avatar-customization.css">
<link rel="manifest" href="./manifest.json">
<script type="importmap">
{
@@ -398,7 +397,6 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
import websockets
from nexus.bannerlord_trace import BannerlordTraceLogger
from bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION

View File

@@ -304,43 +304,6 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
sys.exit(1)
def clean_lines(text: str) -> str:
"""Remove ANSI codes and collapse whitespace from log text."""
import re
text = strip_ansi(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def normalize_event(event: dict) -> dict:
"""Normalize an Evennia event dict to standard format."""
return {
"type": event.get("type", "unknown"),
"actor": event.get("actor", event.get("name", "")),
"room": event.get("room", event.get("location", "")),
"message": event.get("message", event.get("text", "")),
"timestamp": event.get("timestamp", ""),
}
def parse_room_output(text: str) -> dict:
"""Parse Evennia room output into structured data."""
import re
lines = text.strip().split("\n")
result = {"name": "", "description": "", "exits": [], "objects": []}
if lines:
result["name"] = strip_ansi(lines[0]).strip()
if len(lines) > 1:
result["description"] = strip_ansi(lines[1]).strip()
for line in lines[2:]:
line = strip_ansi(line).strip()
if line.startswith("Exits:"):
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
elif line.startswith("You see:"):
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
return result
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")

View File

@@ -1,105 +0,0 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;