Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
8992c951a3 fix: [OPS] timmy-home backlog reduced from 220 to 50 — triage cadence needed (closes #685)
Some checks failed
Smoke Test / smoke (pull_request) Failing after 13s
2026-04-14 21:33:03 -04:00
3 changed files with 224 additions and 0 deletions

153
scripts/backlog_triage.py Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
backlog_triage.py — Weekly backlog health check for timmy-home.
Queries Gitea API for open issues and reports:
- Unassigned issues
- Issues with no labels
- Batch-pipeline issues (triaged with comments)
Usage:
python scripts/backlog_triage.py [--token TOKEN] [--repo OWNER/REPO]
Exit codes:
0 = backlog healthy (no action needed)
1 = issues found requiring attention
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError
GITEA_BASE = os.environ.get("GITEA_BASE_URL", "https://forge.alexanderwhitestone.com/api/v1")
def fetch_issues(owner: str, repo: str, token: str, state: str = "open") -> list:
"""Fetch all open issues from Gitea."""
issues = []
page = 1
per_page = 50
while True:
url = f"{GITEA_BASE}/repos/{owner}/{repo}/issues?state={state}&page={page}&per_page={per_page}&type=issues"
req = Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urlopen(req) as resp:
batch = json.loads(resp.read())
except URLError as e:
print(f"ERROR: Failed to fetch issues: {e}", file=sys.stderr)
sys.exit(2)
if not batch:
break
issues.extend(batch)
page += 1
return issues
def categorize_issues(issues: list) -> dict:
"""Categorize issues into triage buckets."""
unassigned = []
no_labels = []
batch_pipeline = []
for issue in issues:
# Skip pull requests (Gitea includes them in issues endpoint)
if "pull_request" in issue:
continue
number = issue["number"]
title = issue["title"]
assignee = issue.get("assignee")
labels = issue.get("labels", [])
if not assignee:
unassigned.append({"number": number, "title": title})
if not labels:
no_labels.append({"number": number, "title": title})
if "batch-pipeline" in title.lower() or any(
lbl.get("name", "").lower() == "batch-pipeline" for lbl in labels
):
batch_pipeline.append({"number": number, "title": title})
return {
"unassigned": unassigned,
"no_labels": no_labels,
"batch_pipeline": batch_pipeline,
}
def print_report(owner: str, repo: str, categories: dict) -> int:
"""Print triage report and return count of issues needing attention."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"# Backlog Triage Report — {owner}/{repo}")
print(f"Generated: {now}\n")
total_attention = 0
# Unassigned
print(f"## Unassigned Issues ({len(categories['unassigned'])})")
if categories["unassigned"]:
total_attention += len(categories["unassigned"])
for item in categories["unassigned"]:
print(f" - #{item['number']}: {item['title']}")
else:
print(" ✓ None")
print()
# No labels
print(f"## Issues with No Labels ({len(categories['no_labels'])})")
if categories["no_labels"]:
total_attention += len(categories["no_labels"])
for item in categories["no_labels"]:
print(f" - #{item['number']}: {item['title']}")
else:
print(" ✓ None")
print()
# Batch-pipeline
print(f"## Batch-Pipeline Issues ({len(categories['batch_pipeline'])})")
if categories["batch_pipeline"]:
for item in categories["batch_pipeline"]:
print(f" - #{item['number']}: {item['title']}")
else:
print(" ✓ None")
print()
print(f"---\nTotal issues requiring attention: {total_attention}")
return total_attention
def main():
parser = argparse.ArgumentParser(description="Weekly backlog triage for timmy-home")
parser.add_argument("--token", default=os.environ.get("GITEA_TOKEN", ""),
help="Gitea API token (or set GITEA_TOKEN env)")
parser.add_argument("--repo", default="Timmy_Foundation/timmy-home",
help="Repository in OWNER/REPO format")
args = parser.parse_args()
if not args.token:
print("ERROR: No Gitea token provided. Set GITEA_TOKEN or use --token.", file=sys.stderr)
sys.exit(2)
owner, repo = args.repo.split("/", 1)
issues = fetch_issues(owner, repo, args.token)
categories = categorize_issues(issues)
needs_attention = print_report(owner, repo, categories)
sys.exit(1 if needs_attention > 0 else 0)
if __name__ == "__main__":
main()

22
scripts/backlog_triage_cron.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# backlog_triage_cron.sh — Weekly cron wrapper for backlog_triage.py
# Add to crontab: 0 9 * * 1 /path/to/timmy-home/scripts/backlog_triage_cron.sh
# Runs Monday 9am UTC
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
REPORT_DIR="$REPO_DIR/reports/production"
REPORT_FILE="$REPORT_DIR/backlog_triage_$(date +%Y%m%d).md"
mkdir -p "$REPORT_DIR"
# Run triage, capture output
OUTPUT=$("$SCRIPT_DIR/backlog_triage.py" 2>&1) || true
# Save report
echo "$OUTPUT" > "$REPORT_FILE"
# Print to stdout for cron logging
echo "$OUTPUT"

View File

@@ -0,0 +1,49 @@
"""Tests for backlog_triage.py categorization logic."""
import pytest
from scripts.backlog_triage import categorize_issues
def test_unassigned_issues():
issues = [
{"number": 1, "title": "Fix bug", "assignee": None, "labels": []},
{"number": 2, "title": "Feature", "assignee": {"login": "user"}, "labels": []},
]
result = categorize_issues(issues)
assert len(result["unassigned"]) == 1
assert result["unassigned"][0]["number"] == 1
def test_no_labels():
issues = [
{"number": 1, "title": "No label", "assignee": None, "labels": []},
{"number": 2, "title": "Has label", "assignee": None, "labels": [{"name": "bug"}]},
]
result = categorize_issues(issues)
assert len(result["no_labels"]) == 1
assert result["no_labels"][0]["number"] == 1
def test_batch_pipeline():
issues = [
{"number": 1, "title": "batch-pipeline: update genome", "assignee": None, "labels": []},
{"number": 2, "title": "Normal issue", "assignee": None, "labels": [{"name": "batch-pipeline"}]},
{"number": 3, "title": "Other", "assignee": None, "labels": []},
]
result = categorize_issues(issues)
assert len(result["batch_pipeline"]) == 2
numbers = {i["number"] for i in result["batch_pipeline"]}
assert numbers == {1, 2}
def test_skips_pull_requests():
issues = [
{"number": 1, "title": "Issue", "assignee": None, "labels": []},
{"number": 2, "title": "PR", "assignee": None, "labels": [], "pull_request": {}},
]
result = categorize_issues(issues)
# Only issue #1 should be counted, PR #2 excluded
assert len(result["unassigned"]) == 1
assert result["unassigned"][0]["number"] == 1
assert len(result["no_labels"]) == 1
assert result["no_labels"][0]["number"] == 1
assert len(result["batch_pipeline"]) == 0