Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 9s
PR Checklist / pr-checklist (pull_request) Failing after 1m18s
Smoke Test / smoke (pull_request) Failing after 7s
Validate Config / YAML Lint (pull_request) Failing after 6s
Validate Config / JSON Validate (pull_request) Successful in 6s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 7s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Shell Script Lint (pull_request) Failing after 14s
Validate Config / Cron Syntax Check (pull_request) Successful in 5s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 5s
Validate Config / Playbook Schema Validation (pull_request) Successful in 6s
Architecture Lint / Lint Repository (pull_request) Failing after 7s
logging.warning and continue were at same indent level as the if statement instead of inside the if block.
634 lines
22 KiB
Python
634 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Workforce Manager - Epic #204 / Milestone #218
|
|
|
|
Reads fleet routing, Wolf evaluation scores, and open Gitea issues across
|
|
Timmy_Foundation repos. Assigns each issue to the best-available agent,
|
|
tracks success rates, and dispatches work.
|
|
|
|
Usage:
|
|
python workforce-manager.py # Scan, assign, dispatch
|
|
python workforce-manager.py --dry-run # Show assignments without dispatching
|
|
python workforce-manager.py --status # Show agent status and open issue count
|
|
python workforce-manager.py --cron # Run silently, save to log
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
print("FATAL: requests is required. pip install requests", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FLEET_ROUTING_PATH = Path.home() / ".hermes" / "fleet-routing.json"
|
|
WOLF_RESULTS_DIR = Path.home() / ".hermes" / "wolf" / "results"
|
|
GITEA_TOKEN_PATH = Path.home() / ".hermes" / "gitea_token_vps"
|
|
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
|
WORKFORCE_STATE_PATH = Path.home() / ".hermes" / "workforce-state.json"
|
|
ORG_NAME = "Timmy_Foundation"
|
|
|
|
# Role-to-agent-role mapping heuristics
|
|
ROLE_KEYWORDS = {
|
|
"code-generation": [
|
|
"code", "implement", "feature", "function", "class", "script",
|
|
"build", "create", "add", "module", "component",
|
|
],
|
|
"issue-triage": [
|
|
"triage", "categorize", "tag", "label", "organize",
|
|
"backlog", "sort", "prioritize", "review issue",
|
|
],
|
|
"on-request-queries": [
|
|
"query", "search", "lookup", "find", "check",
|
|
"info", "report", "status",
|
|
],
|
|
"devops": [
|
|
"deploy", "ci", "cd", "pipeline", "docker", "container",
|
|
"server", "infrastructure", "config", "nginx", "cron",
|
|
"setup", "install", "environment", "provision",
|
|
"build", "release", "workflow",
|
|
],
|
|
"documentation": [
|
|
"doc", "readme", "document", "write", "guide",
|
|
"spec", "wiki", "changelog", "tutorial",
|
|
"explain", "describe",
|
|
],
|
|
"code-review": [
|
|
"review", "refactor", "fix", "bug", "debug",
|
|
"test", "lint", "style", "improve",
|
|
"clean up", "optimize", "performance",
|
|
],
|
|
"triage-routing": [
|
|
"route", "assign", "triage", "dispatch",
|
|
"organize", "categorize",
|
|
],
|
|
"small-tasks": [
|
|
"small", "quick", "minor", "typo", "label",
|
|
"update", "rename", "cleanup",
|
|
],
|
|
"inactive": [],
|
|
"unknown": [],
|
|
}
|
|
|
|
# Priority keywords (higher = more urgent, route to more capable agent)
|
|
PRIORITY_KEYWORDS = {
|
|
"critical": 5,
|
|
"urgent": 4,
|
|
"block": 4,
|
|
"bug": 3,
|
|
"fix": 3,
|
|
"security": 5,
|
|
"deploy": 2,
|
|
"feature": 1,
|
|
"enhancement": 1,
|
|
"documentation": 1,
|
|
"cleanup": 0,
|
|
}
|
|
|
|
# Cost tier priority (lower index = prefer first)
|
|
TIER_ORDER = ["free", "cheap", "prepaid", "unknown"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_json(path: Path) -> Any:
|
|
if not path.exists():
|
|
logging.warning("File not found: %s", path)
|
|
return None
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def load_fleet_routing() -> List[dict]:
|
|
data = load_json(FLEET_ROUTING_PATH)
|
|
if data and "agents" in data:
|
|
return data["agents"]
|
|
return []
|
|
|
|
|
|
def load_wolf_scores() -> Dict[str, dict]:
|
|
"""Load Wolf evaluation scores from results directory."""
|
|
scores: Dict[str, dict] = {}
|
|
if not WOLF_RESULTS_DIR.exists():
|
|
return scores
|
|
for f in sorted(WOLF_RESULTS_DIR.glob("*.json")):
|
|
data = load_json(f)
|
|
if data and "model_scores" in data:
|
|
for entry in data["model_scores"]:
|
|
model = entry.get("model", "")
|
|
if model:
|
|
scores[model] = entry
|
|
return scores
|
|
|
|
|
|
def load_workforce_state() -> dict:
|
|
if WORKFORCE_STATE_PATH.exists():
|
|
return load_json(WORKFORCE_STATE_PATH) or {}
|
|
return {"assignments": [], "agent_stats": {}, "last_run": None}
|
|
|
|
|
|
def save_workforce_state(state: dict) -> None:
|
|
WORKFORCE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(WORKFORCE_STATE_PATH, "w") as f:
|
|
json.dump(state, f, indent=2)
|
|
logging.info("Workforce state saved to %s", WORKFORCE_STATE_PATH)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gitea API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class GiteaAPI:
|
|
"""Thin wrapper for Gitea REST API."""
|
|
|
|
def __init__(self, token: str, base_url: str = GITEA_API_BASE):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
"Authorization": f"token {token}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
})
|
|
|
|
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
r = self.session.get(f"{self.base_url}{path}", params=params)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def _post(self, path: str, data: dict) -> Any:
|
|
r = self.session.post(f"{self.base_url}{path}", json=data)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def _patch(self, path: str, data: dict) -> Any:
|
|
r = self.session.patch(f"{self.base_url}{path}", json=data)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def get_org_repos(self, org: str) -> List[dict]:
|
|
return self._get(f"/orgs/{org}/repos", params={"limit": 100})
|
|
|
|
def get_open_issues(self, owner: str, repo: str, page: int = 1) -> List[dict]:
|
|
params = {"state": "open", "type": "issues", "limit": 50, "page": page}
|
|
return self._get(f"/repos/{owner}/{repo}/issues", params=params)
|
|
|
|
def get_all_open_issues(self, org: str) -> List[dict]:
|
|
"""Fetch all open issues across all org repos."""
|
|
repos = self.get_org_repos(org)
|
|
all_issues = []
|
|
for repo in repos:
|
|
name = repo["name"]
|
|
try:
|
|
# Paginate through all issues
|
|
page = 1
|
|
while True:
|
|
issues = self.get_open_issues(org, name, page=page)
|
|
if not issues:
|
|
break
|
|
all_issues.extend(issues)
|
|
if len(issues) < 50:
|
|
break
|
|
page += 1
|
|
logging.info("Loaded %d open issues from %s/%s", len(all_issues), org, name)
|
|
except Exception as exc:
|
|
logging.warning("Failed to load issues from %s/%s: %s", org, name, exc)
|
|
return all_issues
|
|
|
|
def add_issue_comment(self, owner: str, repo: str, issue_num: int, body: str) -> dict:
|
|
return self._post(f"/repos/{owner}/{repo}/issues/{issue_num}/comments", {"body": body})
|
|
|
|
def add_issue_label(self, owner: str, repo: str, issue_num: int, label: str) -> dict:
|
|
return self._post(
|
|
f"/repos/{owner}/{repo}/issues/{issue_num}/labels",
|
|
{"labels": [label]},
|
|
)
|
|
|
|
def assign_issue(self, owner: str, repo: str, issue_num: int, assignee: str) -> dict:
|
|
return self._patch(
|
|
f"/repos/{owner}/{repo}/issues/{issue_num}",
|
|
{"assignees": [assignee]},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scoring & Assignment Logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def classify_issue(issue: dict) -> str:
|
|
"""Determine the best agent role for an issue based on title/body."""
|
|
title = (issue.get("title", "") or "").lower()
|
|
body = (issue.get("body", "") or "").lower()
|
|
text = f"{title} {body}"
|
|
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
|
text += " " + " ".join(labels)
|
|
|
|
best_role = "small-tasks" # default
|
|
best_score = 0
|
|
|
|
for role, keywords in ROLE_KEYWORDS.items():
|
|
if not keywords:
|
|
continue
|
|
score = sum(2 for kw in keywords if kw in text)
|
|
# Boost if a matching label exists
|
|
for label in labels:
|
|
if any(kw in label for kw in keywords):
|
|
score += 3
|
|
if score > best_score:
|
|
best_score = score
|
|
best_role = role
|
|
|
|
return best_role
|
|
|
|
|
|
def compute_priority(issue: dict) -> int:
|
|
"""Compute issue priority from keywords."""
|
|
title = (issue.get("title", "") or "").lower()
|
|
body = (issue.get("body", "") or "").lower()
|
|
text = f"{title} {body}"
|
|
return sum(v for k, v in PRIORITY_KEYWORDS.items() if k in text)
|
|
|
|
|
|
def score_agent_for_issue(agent: dict, role: str, wolf_scores: dict, priority: int) -> float:
|
|
"""Score how well an agent matches an issue. Higher is better."""
|
|
score = 0.0
|
|
|
|
# Primary: role match
|
|
agent_role = agent.get("role", "unknown")
|
|
if agent_role == role:
|
|
score += 10.0
|
|
elif agent_role == "small-tasks" and role in ("issue-triage", "on-request-queries"):
|
|
score += 6.0
|
|
elif agent_role == "triage-routing" and role in ("issue-triage", "triage-routing"):
|
|
score += 8.0
|
|
elif agent_role == "code-generation" and role in ("code-review", "devops"):
|
|
score += 4.0
|
|
|
|
# Wolf quality bonus
|
|
model = agent.get("model", "")
|
|
wolf_entry = None
|
|
for wm, ws in wolf_scores.items():
|
|
if model and model.lower() in wm.lower():
|
|
wolf_entry = ws
|
|
break
|
|
if wolf_entry and wolf_entry.get("success"):
|
|
score += wolf_entry.get("total", 0) * 3.0
|
|
|
|
# Cost efficiency: prefer free/cheap for low priority
|
|
tier = agent.get("tier", "unknown")
|
|
tier_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 3
|
|
if priority <= 1 and tier in ("free", "cheap"):
|
|
score += 4.0
|
|
elif priority >= 3 and tier in ("prepaid",):
|
|
score += 3.0
|
|
else:
|
|
score += (3 - tier_idx) * 1.0
|
|
|
|
# Activity bonus
|
|
if agent.get("active", False):
|
|
score += 2.0
|
|
|
|
# Repo familiarity bonus: more repos slightly better
|
|
repo_count = agent.get("repo_count", 0)
|
|
score += min(repo_count * 0.2, 2.0)
|
|
|
|
return round(score, 3)
|
|
|
|
|
|
def find_best_agent(agents: List[dict], role: str, wolf_scores: dict, priority: int,
|
|
exclude: Optional[List[str]] = None) -> Optional[dict]:
|
|
"""Find the best agent for the given role and priority."""
|
|
exclude = exclude or []
|
|
candidates = []
|
|
for agent in agents:
|
|
if agent.get("name") in exclude:
|
|
continue
|
|
if not agent.get("active", False):
|
|
continue
|
|
s = score_agent_for_issue(agent, role, wolf_scores, priority)
|
|
candidates.append((s, agent))
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
return candidates[0][1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def dispatch_assignment(api: GiteaAPI, issue: dict, agent: dict, dry_run: bool = False) -> dict:
|
|
"""Assign an issue to an agent and optionally post a comment."""
|
|
owner = ORG_NAME
|
|
repo = issue.get("repository", {}).get("name", "")
|
|
|
|
# Extract repo from issue repo_url if not in the repository key
|
|
if not repo:
|
|
repo_url = issue.get("repository_url", "")
|
|
if repo_url:
|
|
repo = repo_url.rstrip("/").split("/")[-1]
|
|
|
|
if not repo:
|
|
return {"success": False, "error": "Cannot determine repository for issue"}
|
|
|
|
issue_num = issue.get("number")
|
|
agent_name = agent.get("name", "unknown")
|
|
|
|
comment_body = (
|
|
f"🤖 **Workforce Manager assigned this issue to: @{agent_name}**\n\n"
|
|
f"- **Agent:** {agent_name}\n"
|
|
f"- **Model:** {agent.get('model', 'unknown')}\n"
|
|
f"- **Role:** {agent.get('role', 'unknown')}\n"
|
|
f"- **Tier:** {agent.get('tier', 'unknown')}\n"
|
|
f"- **Assigned at:** {datetime.now(timezone.utc).isoformat()}\n\n"
|
|
f"*Automated assignment by Workforce Manager (Epic #204)*"
|
|
)
|
|
|
|
if dry_run:
|
|
return {
|
|
"success": True,
|
|
"dry_run": True,
|
|
"repo": repo,
|
|
"issue_number": issue_num,
|
|
"assignee": agent_name,
|
|
"comment": comment_body,
|
|
}
|
|
|
|
try:
|
|
api.assign_issue(owner, repo, issue_num, agent_name)
|
|
api.add_issue_comment(owner, repo, issue_num, comment_body)
|
|
return {
|
|
"success": True,
|
|
"repo": repo,
|
|
"issue_number": issue_num,
|
|
"issue_title": issue.get("title", ""),
|
|
"assignee": agent_name,
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"success": False,
|
|
"repo": repo,
|
|
"issue_number": issue_num,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State Tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def update_agent_stats(state: dict, result: dict) -> None:
|
|
"""Update per-agent success tracking."""
|
|
agent_name = result.get("assignee", "unknown")
|
|
if "agent_stats" not in state:
|
|
state["agent_stats"] = {}
|
|
if agent_name not in state["agent_stats"]:
|
|
state["agent_stats"][agent_name] = {
|
|
"total_assigned": 0,
|
|
"successful": 0,
|
|
"failed": 0,
|
|
"success_rate": 0.0,
|
|
"last_assignment": None,
|
|
"assigned_issues": [],
|
|
}
|
|
|
|
stats = state["agent_stats"][agent_name]
|
|
stats["total_assigned"] += 1
|
|
stats["last_assignment"] = datetime.now(timezone.utc).isoformat()
|
|
stats["assigned_issues"].append({
|
|
"repo": result.get("repo", ""),
|
|
"issue_number": result.get("issue_number"),
|
|
"success": result.get("success", False),
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
})
|
|
|
|
if result.get("success"):
|
|
stats["successful"] += 1
|
|
else:
|
|
stats["failed"] += 1
|
|
|
|
total = stats["successful"] + stats["failed"]
|
|
stats["success_rate"] = round(stats["successful"] / total, 3) if total > 0 else 0.0
|
|
|
|
|
|
def print_status(state: dict, agents: List[dict], issues_count: int) -> None:
|
|
"""Print workforce status."""
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Workforce Manager Status - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
|
print(f"{'=' * 60}")
|
|
|
|
# Fleet summary
|
|
active = [a for a in agents if a.get("active")]
|
|
print(f"\nFleet: {len(active)} active agents, {len(agents)} total")
|
|
tier_counts = {}
|
|
for a in active:
|
|
t = a.get("tier", "unknown")
|
|
tier_counts[t] = tier_counts.get(t, 0) + 1
|
|
for t, c in sorted(tier_counts.items()):
|
|
print(f" {t}: {c} agents")
|
|
|
|
# Agent scores
|
|
wolf = load_wolf_scores()
|
|
print(f"\nAgent Details:")
|
|
print(f" {'Name':<25} {'Model':<30} {'Role':<18} {'Tier':<10}")
|
|
for a in agents:
|
|
if not a.get("active"):
|
|
continue
|
|
stats = state.get("agent_stats", {}).get(a["name"], {})
|
|
rate = stats.get("success_rate", 0.0)
|
|
total = stats.get("total_assigned", 0)
|
|
wolf_badge = ""
|
|
for wm, ws in wolf.items():
|
|
if a["model"] and a["model"].lower() in wm.lower() and ws.get("success"):
|
|
wolf_badge = f"[wolf:{ws['total']}]"
|
|
break
|
|
name_str = f"{a['name']} {wolf_badge}"
|
|
if total > 0:
|
|
name_str += f" (s/r: {rate}, n={total})"
|
|
print(f" {name_str:<45} {a.get('role', 'unknown'):<18} {a.get('tier', '?'):<10}")
|
|
|
|
print(f"\nOpen Issues: {issues_count}")
|
|
print(f"Assignments Made: {len(state.get('assignments', []))}")
|
|
if state.get("last_run"):
|
|
print(f"Last Run: {state['last_run']}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Workforce Manager - Assign Gitea issues to AI agents")
|
|
parser.add_argument("--dry-run", action="store_true", help="Show assignments without dispatching")
|
|
parser.add_argument("--status", action="store_true", help="Show workforce status only")
|
|
parser.add_argument("--cron", action="store_true", help="Run silently for cron scheduling")
|
|
parser.add_argument("--label", type=str, help="Only process issues with this label")
|
|
parser.add_argument("--max-issues", type=int, default=100, help="Max issues to process per run")
|
|
args = parser.parse_args()
|
|
|
|
# Setup logging
|
|
if args.cron:
|
|
logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
else:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
|
|
logging.info("Workforce Manager starting")
|
|
|
|
# Load data
|
|
agents = load_fleet_routing()
|
|
if not agents:
|
|
logging.error("No agents found in fleet-routing.json")
|
|
return 1
|
|
logging.info("Loaded %d agents from fleet routing", len(agents))
|
|
|
|
wolf_scores = load_wolf_scores()
|
|
if wolf_scores:
|
|
logging.info("Loaded %d model scores from Wolf results", len(wolf_scores))
|
|
|
|
state = load_workforce_state()
|
|
|
|
# Load Gitea token
|
|
if GITEA_TOKEN_PATH.exists():
|
|
token = GITEA_TOKEN_PATH.read_text().strip()
|
|
else:
|
|
logging.error("Gitea token not found at %s", GITEA_TOKEN_PATH)
|
|
return 1
|
|
|
|
api = GiteaAPI(token)
|
|
|
|
# Status-only mode
|
|
if args.status:
|
|
# Quick open issue count
|
|
repos = api.get_org_repos(ORG_NAME)
|
|
total = sum(r.get("open_issues_count", 0) for r in repos)
|
|
print_status(state, agents, total)
|
|
return 0
|
|
|
|
# Fetch open issues
|
|
if not args.cron:
|
|
print(f"Scanning open issues across {ORG_NAME} repos...")
|
|
|
|
issues = api.get_all_open_issues(ORG_NAME)
|
|
|
|
# Filter by label
|
|
if args.label:
|
|
issues = [
|
|
i for i in issues
|
|
if any(args.label in (l.get("name", "") or "").lower() for l in i.get("labels", []))
|
|
]
|
|
|
|
if args.label:
|
|
logging.info("Filtered to %d issues with label '%s'", len(issues), args.label)
|
|
else:
|
|
logging.info("Found %d open issues", len(issues))
|
|
|
|
# Skip issues already assigned
|
|
already_assigned_nums = set()
|
|
for a in state.get("assignments", []):
|
|
already_assigned_nums.add((a.get("repo"), a.get("issue_number")))
|
|
|
|
issues = [
|
|
i for i in issues
|
|
if not i.get("assignee") and
|
|
not (i.get("repository", {}).get("name"), i.get("number")) in already_assigned_nums
|
|
]
|
|
logging.info("%d unassigned issues to process", len(issues))
|
|
|
|
# Sort by priority
|
|
issues_with_priority = [(compute_priority(i), i) for i in issues]
|
|
issues_with_priority.sort(key=lambda x: x[0], reverse=True)
|
|
issues = [i for _, i in issues_with_priority[:args.max_issues]]
|
|
|
|
# Assign issues
|
|
assignments = []
|
|
agent_exclusions: Dict[str, List[str]] = {} # repo -> list of assigned agents per run
|
|
global_exclusions: List[str] = [] # agents already at capacity per run
|
|
max_per_agent_per_run = 5
|
|
|
|
for issue in issues:
|
|
role = classify_issue(issue)
|
|
priority = compute_priority(issue)
|
|
repo = issue.get("repository", {}).get("name", "")
|
|
|
|
# Avoid assigning same agent twice to same repo in one run
|
|
repo_excluded = agent_exclusions.get(repo, [])
|
|
|
|
# Also exclude agents already at assignment cap
|
|
cap_excluded = [
|
|
name for name, stats in state.get("agent_stats", {}).items()
|
|
if stats.get("total_assigned", 0) > max_per_agent_per_run
|
|
]
|
|
|
|
excluded = list(set(repo_excluded + global_exclusions + cap_excluded))
|
|
|
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=excluded)
|
|
if not agent:
|
|
# Relax exclusions if no agent found
|
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
|
if not agent:
|
|
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
|
issue.get("number"), issue.get("title", ""), role)
|
|
continue
|
|
|
|
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
|
assignments.append(result)
|
|
update_agent_stats(state, result)
|
|
|
|
# Track per-repo exclusions
|
|
if repo not in agent_exclusions:
|
|
agent_exclusions[repo] = []
|
|
agent_exclusions[repo].append(agent["name"])
|
|
|
|
if args.dry_run:
|
|
print(f" [DRY] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
|
else:
|
|
status_str = "OK" if result.get("success") else "FAIL"
|
|
print(f" [{status_str}] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
|
|
|
# Save state
|
|
state["assignments"].extend([{
|
|
"repo": a.get("repo"),
|
|
"issue_number": a.get("issue_number"),
|
|
"assignee": a.get("assignee"),
|
|
"success": a.get("success", False),
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
} for a in assignments])
|
|
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
|
save_workforce_state(state)
|
|
|
|
# Summary
|
|
ok = sum(1 for a in assignments if a.get("success"))
|
|
fail = len(assignments) - ok
|
|
logging.info("Done: %d assigned, %d succeeded, %d failed", len(assignments), ok, fail)
|
|
|
|
if not args.cron:
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Summary: {len(assignments)} assignments, {ok} OK, {fail} failed")
|
|
# Show agent stats
|
|
for name, stats in state.get("agent_stats", {}).items():
|
|
if stats.get("total_assigned", 0) > 0:
|
|
print(f" @{name}: {stats['successful']}/{stats['total_assigned']} ({stats.get('success_rate', 0):.0%} success)")
|
|
print(f"{'=' * 60}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|