#!/usr/bin/env python3 """Cross-agent task delegator for Gitea issues. Refs: timmy-home #550 Phase-3 coordination slice: - inspect open Gitea issues - route clear work items to wizard houses - assign through Gitea when explicitly applied - stay conservative on ambiguous issues """ from __future__ import annotations import argparse import json import os from pathlib import Path from urllib import request from typing import Any DEFAULT_BASE_URL = "https://forge.alexanderwhitestone.com/api/v1" DEFAULT_OWNER = "Timmy_Foundation" DEFAULT_REPO = "timmy-home" DEFAULT_TOKEN_FILE = Path.home() / ".config" / "gitea" / "token" ROUTING_RULES = { "timmy": { "route": "critical", "label_terms": {"critical", "security", "policy", "sovereign", "urgent", "p0"}, "title_terms": [ "critical", "security", "sovereign", "policy", "final decision", "review gate", "approval", ], "body_terms": [ "critical", "security", "sovereign", "final decision", "manual review", "approval", ], }, "ezra": { "route": "documentation", "label_terms": {"documentation", "docs", "analysis", "research", "audit", "genome"}, "title_terms": [ "documentation", "docs", "analysis", "research", "audit", "genome", "architecture", "readme", ], "body_terms": [ "documentation", "analysis", "research", "audit", "architecture", "writeup", "report", ], }, "bezalel": { "route": "implementation", "label_terms": {"bug", "feature", "testing", "tests", "ci", "build", "fix"}, "title_terms": [ "fix", "build", "test", "tests", "ci", "feature", "implementation", "deploy", "pipeline", ], "body_terms": [ "implementation", "testing", "build", "ci", "fix", "feature", "deploy", "proof", ], }, "allegro": { "route": "routing", "label_terms": {"ops", "routing", "dispatch", "coordination", "connectivity", "orchestration"}, "title_terms": [ "routing", "dispatch", "coordination", "connectivity", "orchestration", "queue", "tempo", "handoff", ], "body_terms": [ "routing", "dispatch", "coordination", "connectivity", "orchestration", "agent-to-agent", "cross-fleet", ], }, } class GiteaClient: def __init__(self, token: str, owner: str = DEFAULT_OWNER, repo: str = DEFAULT_REPO, base_url: str = DEFAULT_BASE_URL): self.token = token self.owner = owner self.repo = repo self.base_url = base_url.rstrip("/") def _request(self, path: str, *, method: str = "GET", data: dict[str, Any] | None = None): payload = None if data is None else json.dumps(data).encode() headers = {"Authorization": f"token {self.token}"} if payload is not None: headers["Content-Type"] = "application/json" req = request.Request(f"{self.base_url}{path}", data=payload, headers=headers, method=method) with request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) def list_open_issues(self, limit: int = 100): issues = self._request(f"/repos/{self.owner}/{self.repo}/issues?state=open&limit={limit}") return [issue for issue in issues if not issue.get("pull_request")] def assign_issue(self, issue_number: int, assignee: str): return self._request( f"/repos/{self.owner}/{self.repo}/issues/{issue_number}", method="PATCH", data={"assignees": [assignee]}, ) def _labels(issue: dict[str, Any]) -> set[str]: return {str(label.get("name", "")).strip().lower() for label in (issue.get("labels") or []) if label.get("name")} def _score_rule(text_title: str, text_body: str, labels: set[str], rule: dict[str, Any]): score = 0 matched_terms: list[str] = [] for term in sorted(rule["label_terms"]): if term in labels: score += 3 matched_terms.append(term) for term in rule["title_terms"]: if term in text_title: score += 2 matched_terms.append(term) for term in rule["body_terms"]: if term in text_body: score += 1 matched_terms.append(term) deduped = [] seen = set() for term in matched_terms: if term in seen: continue seen.add(term) deduped.append(term) return score, deduped def classify_issue(issue: dict[str, Any], minimum_confidence: int = 3): title = str(issue.get("title") or "").lower() body = str(issue.get("body") or "").lower() labels = _labels(issue) scored = [] for assignee, rule in ROUTING_RULES.items(): score, matched_terms = _score_rule(title, body, labels, rule) if score > 0: scored.append((score, assignee, rule["route"], matched_terms)) if not scored: return None scored.sort(key=lambda item: (-item[0], item[1])) best_score, best_assignee, route, matched_terms = scored[0] if best_score < minimum_confidence: return None if len(scored) > 1 and scored[1][0] == best_score: return None return { "assignee": best_assignee, "route": route, "confidence": best_score, "matched_terms": matched_terms, } def build_assignment_plan(issues: list[dict[str, Any]], minimum_confidence: int = 3): assignments = [] skipped = [] for issue in issues: issue_number = issue.get("number") if issue.get("pull_request"): skipped.append({"issue_number": issue_number, "reason": "pull_request"}) continue assignees = issue.get("assignees") or [] if assignees: skipped.append({"issue_number": issue_number, "reason": "already_assigned"}) continue recommendation = classify_issue(issue, minimum_confidence=minimum_confidence) if recommendation is None: skipped.append({"issue_number": issue_number, "reason": "no_confident_route"}) continue assignments.append({ "issue_number": issue_number, **recommendation, }) assignments.sort(key=lambda item: (-item["confidence"], item["issue_number"])) return {"assignments": assignments, "skipped": skipped} def apply_assignment_plan(plan: dict[str, Any], client: GiteaClient, apply: bool = False): results = [] for item in plan.get("assignments", []): if apply: client.assign_issue(item["issue_number"], item["assignee"]) results.append({ "action": "assigned", "issue_number": item["issue_number"], "assignee": item["assignee"], }) else: results.append({ "action": "would_assign", "issue_number": item["issue_number"], "assignee": item["assignee"], }) return results def parse_args(): parser = argparse.ArgumentParser(description="Route open Gitea issues to wizard houses and optionally assign them.") parser.add_argument("--owner", default=DEFAULT_OWNER) parser.add_argument("--repo", default=DEFAULT_REPO) parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_FILE) parser.add_argument("--limit", type=int, default=100) parser.add_argument("--minimum-confidence", type=int, default=3) parser.add_argument("--apply", action="store_true", help="Apply assignments to Gitea instead of reporting them.") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON output.") return parser.parse_args() def main(): args = parse_args() if not args.token_file.exists(): raise SystemExit(f"Token file not found: {args.token_file}") token = args.token_file.read_text().strip() client = GiteaClient(token=token, owner=args.owner, repo=args.repo, base_url=args.base_url) issues = client.list_open_issues(limit=args.limit) plan = build_assignment_plan(issues, minimum_confidence=args.minimum_confidence) results = apply_assignment_plan(plan, client, apply=args.apply) payload = { "assignments": plan["assignments"], "skipped": plan["skipped"], "results": results, } if args.json: print(json.dumps(payload, indent=2)) return print("--- Gitea Task Delegator ---") print(f"Assignments: {len(plan['assignments'])}") for item in plan["assignments"]: print(f"- #{item['issue_number']} -> {item['assignee']} ({item['route']}, confidence={item['confidence']})") print(f"Skipped: {len(plan['skipped'])}") for item in plan["skipped"][:20]: print(f"- #{item['issue_number']}: {item['reason']}") if __name__ == "__main__": main()