Compare commits
1 Commits
fix/673
...
sprint/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8992c951a3 |
153
scripts/backlog_triage.py
Executable file
153
scripts/backlog_triage.py
Executable 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
22
scripts/backlog_triage_cron.sh
Executable 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"
|
||||
49
tests/test_backlog_triage.py
Normal file
49
tests/test_backlog_triage.py
Normal 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
|
||||
Reference in New Issue
Block a user